diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9ef41d..5f8c7b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,11 @@ name: CI -on: +on: push: branches-ignore: [release] +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest 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 0c2d5cf..aa1cca5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,80 +1,82 @@ { "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/cookie-parser": "^1.4.10", "@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", + "cookie-parser": "^1.4.7", + "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 +85,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 +208,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 +215,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 +248,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 +268,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 +284,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 +299,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 +376,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 +990,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 +1018,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 +1061,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 +1075,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 +1089,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 +1110,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 +1124,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 +1166,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 +1238,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 +1265,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 +1293,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 +1310,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 +1338,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 +1408,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 +1471,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 +1494,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 +1573,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 +1615,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 +1639,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 +1750,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 +2045,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 +2063,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 +2110,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 +2120,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 +2163,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 +2191,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 +2221,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 +2264,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 +2349,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 +2380,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 +2396,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 +2412,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 +2438,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 +2766,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 +2845,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 +2888,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 +2907,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 +2930,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 +2946,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 +2962,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 +2978,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 +2994,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 +3009,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 +3025,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 +3041,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 +3106,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 +3129,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 +3139,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": { @@ -3276,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", @@ -3328,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", @@ -3424,19 +3408,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 +3574,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 +3597,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 +3613,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 +3635,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 +3657,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 +3679,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 +3692,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 +3717,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 +3736,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 +3760,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 +3824,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 +4381,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 +4676,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 +4686,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 +4708,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 +4723,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 +4765,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 +4805,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 +4843,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 +4868,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 +4883,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 +4915,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 +4970,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 +5069,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 +5565,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": { @@ -5670,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", @@ -6135,9 +6134,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 +6147,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 +6178,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 +6651,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 +6689,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,71 +6758,702 @@ "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==", + "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": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" + "@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-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/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": { - "ms": "^2.1.1" + "@angular-eslint/bundled-angular-compiler": "20.7.0", + "@angular-eslint/utils": "20.7.0", + "ts-api-utils": "^2.1.0" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "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-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": { - "esquery": "^1.6.0" - }, - "engines": { - "node": ">=12" + "@angular-eslint/bundled-angular-compiler": "20.7.0", + "@angular-eslint/utils": "20.7.0", + "aria-query": "5.3.2", + "axobject-query": "4.1.0" }, "peerDependencies": { - "eslint": "*", - "jsonc-eslint-parser": "^2.4.0 || ^3.0.0" - }, - "peerDependenciesMeta": { - "@eslint/json": { - "optional": true - } + "@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": { - "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/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": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" + "@angular-eslint/bundled-angular-compiler": "20.7.0", + "eslint-scope": "^9.0.0" }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } + "peerDependencies": { + "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", + "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": { + "@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", @@ -6890,57 +7520,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 +7530,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 +7650,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 +7698,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 +7834,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 +7845,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 +7876,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 +8045,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 +8215,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 +8288,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 +8567,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 +9469,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 +9773,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 +9801,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 +9816,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 +9848,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 +9881,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 +9932,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 +10004,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 +10033,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 +10094,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 +10145,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 +10188,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 +10208,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 +10256,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 +10290,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 +10362,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 +10373,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 +10395,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 +10444,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 +10464,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 +11153,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 +11262,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 +11323,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 +11408,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 +11810,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 +12232,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 +12267,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 +12282,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 +12507,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 +12518,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 +12538,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 +12568,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": { @@ -12183,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", @@ -12221,15 +12694,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 +12839,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 +13853,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 +14268,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 +14294,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 +14309,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 +14330,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 +14378,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 +14390,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 +14418,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 +14465,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 +14498,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 +14511,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 +14540,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 +14563,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 +14810,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 +14830,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 +15056,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 +15076,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 +15119,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 +15129,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..8afd254 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,60 @@ "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" + "xmlbuilder2": "^4.0.3", + "cookie-parser": "^1.4.7" }, "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/cookie-parser": "^1.4.10", "@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/assets/public/vendor/manifest.json b/sandbox/assets/public/vendor/manifest.json index dcd3795..c4e67de 100644 --- a/sandbox/assets/public/vendor/manifest.json +++ b/sandbox/assets/public/vendor/manifest.json @@ -1,17 +1,8 @@ { - "MetricsPage": [ - "chart.js" - ], - "NetworkChart": [ - "chart.js" - ], - "RequestDurationChart": [ - "chart.js" - ], - "RequestsPerSecondChart": [ + "Chart": [ "chart.js" ], - "ResourceUsageChart": [ + "MetricsPage": [ "chart.js" ], "SocketIoTestPage": [ diff --git a/sandbox/src/controllers/metrics.controller.ts b/sandbox/src/controllers/metrics.controller.ts index 71fa25a..12d0734 100644 --- a/sandbox/src/controllers/metrics.controller.ts +++ b/sandbox/src/controllers/metrics.controller.ts @@ -1,12 +1,15 @@ -import { Controller, Inject, ZIBRI_DI_TOKENS, Metric, Get, Response, MetricsSnapshot, MetricsServiceInterface, HtmlResponse, PreactUtilities, GlobalRegistry } from 'zibri'; +import { Controller, Inject, ZIBRI_DI_TOKENS, Metric, Get, Response, MetricsSnapshot, MetricsServiceInterface, HtmlResponse, PreactUtilities, GlobalRegistry, CacheServiceInterface, Cached } from 'zibri'; +import { StaticPagesCache } from './page.controller'; import { MetricsPage } from '../templates/pages/metrics'; @Controller('/metrics') export class MetricsController { constructor( @Inject(ZIBRI_DI_TOKENS.METRICS_SERVICE) - private readonly metricsService: MetricsServiceInterface + private readonly metricsService: MetricsServiceInterface, + @Inject(ZIBRI_DI_TOKENS.CACHE_SERVICE) + private readonly cacheService: CacheServiceInterface ) {} @Response.array(Metric) @@ -15,10 +18,16 @@ export class MetricsController { return this.metricsService.getMetricSnapshots(); } + @Cached(StaticPagesCache, () => 'dashboard') @Response.html() @Get('/dashboard') async dashboard(): Promise { const version: string = GlobalRegistry.getAppData('version') ?? '-'; - return await PreactUtilities.renderResponse(MetricsPage, { version, primary: '#0e456f', secondary: '#00b4d8' }); + const cacheNames: string[] = this.cacheService.caches.map(c => c.name); + const html: string = await PreactUtilities.renderPage( + MetricsPage, + { version, cacheNames, 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..b9164e1 100644 --- a/sandbox/src/controllers/page.controller.ts +++ b/sandbox/src/controllers/page.controller.ts @@ -1,22 +1,40 @@ -import { AssetServiceInterface, Controller, Get, GlobalRegistry, HtmlResponse, inject, PreactUtilities, Response, TreeNode, 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( + @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(), []); + } +} + @Controller('/') export class PageController { + @Cached(StaticPagesCache, () => 'index') @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); } + @Cached(StaticPagesCache, () => 'assets') @Response.html() @Get('/assets') 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..5678fc5 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() @@ -47,7 +48,6 @@ export class TemplateController { cleanupAt: new Date(), level: logLevel, message: 'test 42', - error: errorToLoggedError(new Error('Something Failed')), context: { origin, request: { @@ -55,7 +55,8 @@ export class TemplateController { url: 'http://localhost:3000/templates/log', clientIp: '123.456.789.10', userAgent: 'Mozilla/Firefox' - } + }, + error: errorToLoggedError(new Error('Something Failed')) } }; diff --git a/sandbox/src/create-default-data.function.ts b/sandbox/src/create-default-data.function.ts index e9a435b..fdd6194 100644 --- a/sandbox/src/create-default-data.function.ts +++ b/sandbox/src/create-default-data.function.ts @@ -1,4 +1,4 @@ -import { DataSourceInterface, HashUtilities, inject, JwtCredentials, JwtCredentialsCreateData, Newable, Repository, repositoryTokenFor, Transaction } from 'zibri'; +import { DataSourceInterface, HashServiceInterface, inject, JwtCredentials, JwtCredentialsCreateData, Newable, Repository, repositoryTokenFor, Transaction, ZIBRI_DI_TOKENS } from 'zibri'; import { logger } from '.'; import { Roles, User } from './models'; @@ -15,6 +15,7 @@ export async function createDefaultData(dataSourceClass: Newable { const userRepository: UserRepository = inject(UserRepository); const credentialsRepository: Repository = inject(repositoryTokenFor(JwtCredentials)); + const hashService: HashServiceInterface = inject(ZIBRI_DI_TOKENS.HASH_SERVICE); const defaultUser: User | undefined = await userRepository.findOne({ where: { email: 'admin@test.com' } }, false); if (defaultUser) { @@ -26,7 +27,7 @@ async function createDefaultAdmin(dataSource: DataSourceInterface): Promise[] = [ useFactory: () => [LoggerTransport.console(LogLevel.INFO)] }), defineProvider({ - token: ZIBRI_DI_TOKENS.JWT_PASSWORD_RESET_EMAIL_TEMPLATE, + token: ZIBRI_DI_TOKENS.ENCRYPTION_MASTER_OPTIONS, + useValue: { + currentMasterStrategy: new AesGcmEncryptionStrategy(), + currentMasterKey: { + id: 'k1', + value: '42' + } + } + }), + defineProvider({ + token: ZIBRI_DI_TOKENS.PASSWORD_RESET_EMAIL_TEMPLATE, useFactory: () => PasswordResetEmail }), defineProvider({ @@ -45,7 +55,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..bea6280 100644 --- a/sandbox/src/repositories/user.repository.ts +++ b/sandbox/src/repositories/user.repository.ts @@ -1,4 +1,4 @@ -import { Inject, inject, InjectRepository, JwtCredentials, LoggerInterface, Repository, repositoryTokenFor, UserRepo, UserRepositoryInterface, ZIBRI_DI_TOKENS } from 'zibri'; +import { getDefaultBeforeReturnHook, getDefaultBeforeSaveHook, Inject, inject, InjectRepository, JwtCredentials, LoggerInterface, Repository, repositoryTokenFor, UserRepo, UserRepositoryInterface, ZIBRI_DI_TOKENS } from 'zibri'; import { Roles, User, UserCreateData } from '../models'; @@ -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, getDefaultBeforeSaveHook(), getDefaultBeforeReturnHook()); } async findByEmail(email: string): Promise { diff --git a/sandbox/src/templates/components/cache-details-section.tsx b/sandbox/src/templates/components/cache-details-section.tsx new file mode 100644 index 0000000..9396280 --- /dev/null +++ b/sandbox/src/templates/components/cache-details-section.tsx @@ -0,0 +1,194 @@ +// cache-detail-section.tsx +import type { Chart as ChartJsChart } from 'chart.js'; +import { MetricsSnapshot, PreactComponent } from 'zibri'; + +import { Chart } from './chart'; + +type Props = { + cacheNames: string[], + primary: string, + secondary: string, + className?: string +}; + +export const CacheDetailSection: PreactComponent = ({ cacheNames, primary, secondary, className = '' }) => { + return ( +
+ {cacheNames.map(name =>
+

{name}

+
+ { + const hitSeries: { x: number, y: number }[] = []; + const missSeries: { x: number, y: number }[] = []; + + for (let i: number = 0; i < snaps.length; i++) { + const snap: MetricsSnapshot = snaps[i]; + const prev: MetricsSnapshot = snaps[i - 1]; + const t: number = new Date(snap.timestamp).getTime(); + + const get: (metricName: string, s: MetricsSnapshot) => number = (metricName, s) => s.metrics + .filter(m => m.name === metricName && m.labels['cache'] === name) + .reduce((p, c) => p + c.value, 0); + + if (i === 0) { + hitSeries.push({ x: t, y: 0 }); + missSeries.push({ x: t, y: 0 }); + } + else { + hitSeries.push({ x: t, y: get('cache_hits_total', snap) - get('cache_hits_total', prev) }); + missSeries.push({ x: t, y: get('cache_misses_total', snap) - get('cache_misses_total', prev) }); + } + } + + chart.data.datasets[0].data = hitSeries; + chart.data.datasets[1].data = missSeries; + chart.update(); + }} + /> + { + if (!snaps.length) { + return; + } + const latest: MetricsSnapshot = snaps[snaps.length - 1]; + + const operations: string[] = [ + ...new Set( + latest.metrics + .filter(m => (m.name === 'cache_source_duration_ms_sum' || m.name === 'cache_store_duration_ms_sum') + && m.labels['cache'] === name + && m.labels['operation'] != undefined) + .map(m => m.labels['operation'] as string) + ) + ]; + + const getMean: (base: string, op: string) => number = (base, op) => { + const sum: number = latest.metrics.find( + m => m.name === `${base}_sum` && m.labels['cache'] === name && m.labels['operation'] === op + )?.value ?? 0; + const count: number = latest.metrics.find( + m => m.name === `${base}_count` && m.labels['cache'] === name && m.labels['operation'] === op + )?.value ?? 0; + return count === 0 ? 0 : Math.round((sum / count) * 10) / 10; + }; + + chart.data.labels = operations; + chart.data.datasets[0].data = operations.map(op => getMean('cache_source_duration_ms', op)); + chart.data.datasets[1].data = operations.map(op => getMean('cache_store_duration_ms', op)); + chart.update(); + }} + /> + { + chart.data.datasets[0].data = snaps.map(snap => ({ + x: new Date(snap.timestamp).getTime(), + y: snap.metrics.find(m => m.name === 'cache_size' && m.labels['cache'] === name)?.value ?? 0 + })); + chart.data.datasets[1].data = snaps.map(snap => ({ + x: new Date(snap.timestamp).getTime(), + y: snap.metrics.find(m => m.name === 'cache_in_flight' && m.labels['cache'] === name)?.value ?? 0 + })); + chart.update(); + }} + /> +
+
)} +
+ ); +}; \ No newline at end of file diff --git a/sandbox/src/templates/components/cache-hit-rate-chart.tsx b/sandbox/src/templates/components/cache-hit-rate-chart.tsx new file mode 100644 index 0000000..e7a6c53 --- /dev/null +++ b/sandbox/src/templates/components/cache-hit-rate-chart.tsx @@ -0,0 +1,71 @@ +import type { Chart as ChartJsChart } from 'chart.js'; +import { MetricsSnapshot, PreactComponent } from 'zibri'; + +import { Chart } from './chart'; + +type Props = { + className?: string, + primary: string +}; + +type DataPoint = { x: Date, y: number }; + +export const CacheHitRateChart: PreactComponent = ({ className = '', primary }) => { + + function updateChart(chart: ChartJsChart<'line', DataPoint[]>, snaps: MetricsSnapshot[]): void { + if (!snaps.length) { + return; + } + + const cacheNames: Set = new Set( + snaps.flatMap(s => s.metrics) + .filter(m => m.name === 'cache_hits_total' && m.labels['cache'] != undefined) + .map(m => m.labels['cache'] as string) + ); + + const dataPerCache: Record = {}; + + for (const snap of snaps) { + const getTotal: (metricName: string, cacheName: string) => number = (metricName, cacheName) => snap.metrics + .filter(m => m.name === metricName && m.labels['cache'] === cacheName) + .reduce((sum, m) => sum + m.value, 0); + + for (const cacheName of cacheNames) { + const hits: number = getTotal('cache_hits_total', cacheName); + const misses: number = getTotal('cache_misses_total', cacheName); + const total: number = hits + misses; + dataPerCache[cacheName] ??= []; + dataPerCache[cacheName].push({ + x: snap.timestamp, + y: total === 0 ? 0 : Math.round((hits / total) * 100) + }); + } + } + + chart.data.datasets = [...cacheNames].map((cacheName, i) => ({ + label: cacheName, + data: dataPerCache[cacheName], + borderColor: i === 0 ? primary : `hsl(${(i * 47) % 360}, 70%, 60%)`, + fill: false, + tension: 0.3 + })); + chart.update(); + } + + return `${v}%` } } + } + } + }} + updateChart={updateChart} + />; +}; \ No newline at end of file diff --git a/sandbox/src/templates/components/cache-hit-rate-stat-card.tsx b/sandbox/src/templates/components/cache-hit-rate-stat-card.tsx new file mode 100644 index 0000000..f4a7006 --- /dev/null +++ b/sandbox/src/templates/components/cache-hit-rate-stat-card.tsx @@ -0,0 +1,42 @@ +import { MetricsSnapshot, onClient, PreactComponent } from 'zibri'; + +import { StatCard } from './stat-card'; +import { MetricsEvent } from '../pages/metrics'; + +type Props = { className?: string }; + +export const CacheHitRateStatCard: PreactComponent = ({ className = '' }) => { + onClient(() => { + document.addEventListener('metrics:update', (ev) => { + if (!(ev instanceof CustomEvent) || !('snaps' in ev.detail)) { + throw new Error('received invalid metrics event'); + } + update((ev as MetricsEvent).detail.snaps); + }); + }); + + function update(snaps: MetricsSnapshot[]): void { + if (snaps.length < 2) { + return; + } + + const latest: MetricsSnapshot = snaps[snaps.length - 1]; + const prev: MetricsSnapshot = snaps[snaps.length - 2]; + + const hitsNow: number = latest.metrics.filter(m => m.name === 'cache_hits_total').reduce((p, c) => p + c.value, 0); + const hitsPrev: number = prev.metrics.filter(m => m.name === 'cache_hits_total').reduce((p, c) => p + c.value, 0); + const missesNow: number = latest.metrics.filter(m => m.name === 'cache_misses_total').reduce((p, c) => p + c.value, 0); + const missesPrev: number = prev.metrics.filter(m => m.name === 'cache_misses_total').reduce((p, c) => p + c.value, 0); + + const deltaHits: number = hitsNow - hitsPrev; + const deltaMisses: number = missesNow - missesPrev; + const total: number = deltaHits + deltaMisses; + + const el: HTMLElement | null = document.getElementById('cacheHitRateStat'); + if (el) { + el.textContent = total === 0 ? '0' : `${Math.round((deltaHits / total) * 100)}`; + } + } + + return ; +}; \ No newline at end of file diff --git a/sandbox/src/templates/components/cache-inflight-stat-card.tsx b/sandbox/src/templates/components/cache-inflight-stat-card.tsx new file mode 100644 index 0000000..99d4027 --- /dev/null +++ b/sandbox/src/templates/components/cache-inflight-stat-card.tsx @@ -0,0 +1,38 @@ +import { MetricsSnapshot, onClient, PreactComponent } from 'zibri'; + +import { StatCard } from './stat-card'; +import { MetricsEvent } from '../pages/metrics'; + +type Props = { + className?: string +}; + +export const CacheInFlightStatCard: PreactComponent = ({ className = '' }) => { + onClient(() => { + document.addEventListener('metrics:update', (ev) => { + if (!(ev instanceof CustomEvent) || !('snaps' in ev.detail)) { + throw new Error('received invalid metrics event'); + } + const { snaps } = (ev as MetricsEvent).detail; + update(snaps); + }); + }); + + function update(snaps: MetricsSnapshot[]): void { + if (!snaps.length) { + return; + } + + const latest: MetricsSnapshot = snaps[snaps.length - 1]; + const inFlight: number = latest.metrics + .filter(m => m.name === 'cache_in_flight') + .reduce((p, c) => p + c.value, 0); + + const el: HTMLElement | null = document.getElementById('cacheInFlightStat'); + if (el) { + el.textContent = String(inFlight); + } + } + + return ; +}; \ No newline at end of file diff --git a/sandbox/src/templates/components/cache-overview-chart.tsx b/sandbox/src/templates/components/cache-overview-chart.tsx new file mode 100644 index 0000000..e69f225 --- /dev/null +++ b/sandbox/src/templates/components/cache-overview-chart.tsx @@ -0,0 +1,124 @@ +import type { Chart as ChartJsChart } from 'chart.js'; +import { MetricsSnapshot, PreactComponent } from 'zibri'; + +import { Chart } from './chart'; + +type Props = { + className?: string, + secondary: string +}; + +type DataPoint = { x: Date, y: number }; + +export const CacheOverviewChart: PreactComponent = ({ secondary, className }) => { + + function updateChart(chart: ChartJsChart<'line', DataPoint[]>, snaps: MetricsSnapshot[]): void { + if (!snaps.length) { + return; + } + + const hitRateSeries: DataPoint[] = snaps.map((snap, i) => { + if (i === 0) { + return { x: new Date(snap.timestamp), y: 0 }; + } + + const prev: MetricsSnapshot = snaps[i - 1]; + const hitsNow: number = snap.metrics.filter(m => m.name === 'cache_hits_total').reduce((p, c) => p + c.value, 0); + const hitsPrev: number = prev.metrics.filter(m => m.name === 'cache_hits_total').reduce((p, c) => p + c.value, 0); + const missesNow: number = snap.metrics.filter(m => m.name === 'cache_misses_total').reduce((p, c) => p + c.value, 0); + const missesPrev: number = prev.metrics.filter(m => m.name === 'cache_misses_total').reduce((p, c) => p + c.value, 0); + + const deltaHits: number = hitsNow - hitsPrev; + const deltaMisses: number = missesNow - missesPrev; + const total: number = deltaHits + deltaMisses; + + return { + x: new Date(snap.timestamp), + y: total === 0 ? 0 : Math.round((deltaHits / total) * 100) + }; + }); + + const errorSeries: DataPoint[] = snaps.map((snap, i) => { + if (i === 0) { + return { x: new Date(snap.timestamp), y: 0 }; + } + + const prev: MetricsSnapshot = snaps[i - 1]; + const errorsNow: number = snap.metrics + .filter(m => m.name === 'cache_invalidation_failures_total' || m.name === 'cache_errors_total') + .reduce((p, c) => p + c.value, 0); + const errorsPrev: number = prev.metrics + .filter(m => m.name === 'cache_invalidation_failures_total' || m.name === 'cache_errors_total') + .reduce((p, c) => p + c.value, 0); + + return { + x: new Date(snap.timestamp), + y: errorsNow - errorsPrev + }; + }); + + chart.data.datasets[0].data = hitRateSeries; + chart.data.datasets[1].data = errorSeries; + + chart.update(); + } + + return <> + `${v}%` }, + title: { display: true, text: 'Hit rate (%)' }, + grid: { display: true } + }, + y1: { + min: 0, + position: 'right', + grid: { drawOnChartArea: false }, + title: { display: true, text: 'Errors' }, + ticks: { stepSize: 1 } + } + } + } + }} + updateChart={updateChart} + /> + ; +}; \ No newline at end of file diff --git a/sandbox/src/templates/components/cache-size-stat-card.tsx b/sandbox/src/templates/components/cache-size-stat-card.tsx new file mode 100644 index 0000000..49b7844 --- /dev/null +++ b/sandbox/src/templates/components/cache-size-stat-card.tsx @@ -0,0 +1,33 @@ +import { MetricsSnapshot, onClient, PreactComponent } from 'zibri'; + +import { StatCard } from './stat-card'; +import { MetricsEvent } from '../pages/metrics'; + +type Props = { className?: string }; + +export const CacheSizeStatCard: PreactComponent = ({ className = '' }) => { + onClient(() => { + document.addEventListener('metrics:update', (ev) => { + if (!(ev instanceof CustomEvent) || !('snaps' in ev.detail)) { + throw new Error('received invalid metrics event'); + } + update((ev as MetricsEvent).detail.snaps); + }); + }); + + function update(snaps: MetricsSnapshot[]): void { + if (!snaps.length) { + return; + } + const latest: MetricsSnapshot = snaps[snaps.length - 1]; + const size: number = latest.metrics + .filter(m => m.name === 'cache_size') + .reduce((p, c) => p + c.value, 0); + const el: HTMLElement | null = document.getElementById('cacheSizeStat'); + if (el) { + el.textContent = String(size); + } + } + + return ; +}; \ No newline at end of file diff --git a/sandbox/src/templates/components/chart.tsx b/sandbox/src/templates/components/chart.tsx index 04ea4a5..12ccd01 100644 --- a/sandbox/src/templates/components/chart.tsx +++ b/sandbox/src/templates/components/chart.tsx @@ -1,15 +1,59 @@ -import { PreactComponent } from 'zibri'; +import { ChartConfiguration, Chart as ChartJsChart } from 'chart.js?client'; +import { MetricsSnapshot, onClient, PreactComponent } from 'zibri'; import { Card } from './card'; import { Heading } from './heading'; +import { MetricsEvent } from '../pages/metrics'; type Props = { title: string, canvasId: string, + chartConfig: ChartConfiguration, + // eslint-disable-next-line typescript/no-explicit-any + updateChart: (chart: ChartJsChart, snaps: MetricsSnapshot[]) => void, className?: string }; -export const Chart: PreactComponent = ({ className = '', title, canvasId }) => { +export const Chart: PreactComponent = ({ className = '', title, canvasId, chartConfig, updateChart }) => { + onClient(() => { + let chart: ChartJsChart | undefined; + + window.addEventListener('load', () => { + const el: HTMLElement | null = document.getElementById(canvasId); + if (!el) { + return; + } + + new IntersectionObserver(([entry]) => { + if (entry.isIntersecting) { + if (!chart) { + const canvas: HTMLCanvasElement | null = document.querySelector(`#${canvasId}`); + if (!canvas) { + return; + } + chart = new ChartJsChart(canvas, chartConfig); + } + else { + chart.update(); + } + } + else { + chart?.stop(); + } + }).observe(el); + }); + + document.addEventListener('metrics:update', (ev) => { + if (!(ev instanceof CustomEvent) || !('snaps' in ev.detail)) { + throw new Error('received invalid metrics event'); + } + if (!chart) { + return; + } + updateChart(chart, (ev as MetricsEvent).detail.snaps); + }); + }); + return ( {title} diff --git a/sandbox/src/templates/components/checkbox.tsx b/sandbox/src/templates/components/checkbox.tsx index c5d8cc4..63ea959 100644 --- a/sandbox/src/templates/components/checkbox.tsx +++ b/sandbox/src/templates/components/checkbox.tsx @@ -2,14 +2,15 @@ import { PreactComponent } from 'zibri'; type Props = { label: string, + className?: string, onChange?: () => void, checked?: boolean, id?: string }; -export const Checkbox: PreactComponent = ({ label, id = label, checked, onChange }) => { +export const Checkbox: PreactComponent = ({ label, id = label, checked, className = '', onChange }) => { return ( -
+
onChange?.()} diff --git a/sandbox/src/templates/components/metrics-status.tsx b/sandbox/src/templates/components/metrics-status.tsx index 7284ee3..8578c85 100644 --- a/sandbox/src/templates/components/metrics-status.tsx +++ b/sandbox/src/templates/components/metrics-status.tsx @@ -1,23 +1,22 @@ import { Metric, MetricsSnapshot, onClient, PreactComponent } from 'zibri'; import { Card } from './card'; -import { Checkbox } from './checkbox'; import { Heading } from './heading'; import { MetricsEvent } from '../pages/metrics'; type Props = { - onReloadChange: () => void, version: string, - automaticReloadChecked: boolean, + id: string, className?: string }; export const MetricsStatus: PreactComponent = ({ className = '', version = '-', - onReloadChange, - automaticReloadChecked + id }) => { + const uptimeInfoId: string = `uptimeInfo-${id}`; + const uptimeInfoSinceId: string = `uptimeInfoSince-${id}`; onClient(() => { document.addEventListener('metrics:update', (ev) => { @@ -55,11 +54,11 @@ export const MetricsStatus: PreactComponent = ({ { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' } ); - const uptimeInfo: HTMLElement | null = document.getElementById('uptimeInfo'); + const uptimeInfo: HTMLElement | null = document.getElementById(uptimeInfoId); if (uptimeInfo) { uptimeInfo.textContent = text; } - const uptimeInfoSince: HTMLElement | null = document.getElementById('uptimeInfoSince'); + const uptimeInfoSince: HTMLElement | null = document.getElementById(uptimeInfoSinceId); if (uptimeInfoSince) { uptimeInfoSince.textContent = sinceText; } @@ -70,8 +69,6 @@ export const MetricsStatus: PreactComponent = ({ Status - onReloadChange()} checked={automaticReloadChecked}> -
Version:
@@ -79,11 +76,11 @@ export const MetricsStatus: PreactComponent = ({
Uptime:
-
...
+
...
Since:
-
...
+
...
diff --git a/sandbox/src/templates/components/network-chart.tsx b/sandbox/src/templates/components/network-chart.tsx index 29be9f9..8d2c7b8 100644 --- a/sandbox/src/templates/components/network-chart.tsx +++ b/sandbox/src/templates/components/network-chart.tsx @@ -1,8 +1,7 @@ -import { ChartDataset, Chart as ChartJsChart } from 'chart.js?client'; -import { MetricsSnapshot, onClient, PreactComponent } from 'zibri'; +import type { Chart as ChartJsChart } from 'chart.js'; +import { MetricsSnapshot, PreactComponent } from 'zibri'; import { Chart } from './chart'; -import { MetricsEvent } from '../pages/metrics'; type Props = { primary: string, @@ -13,17 +12,6 @@ type Props = { type DataPoint = { x: Date, y: number }; export const NetworkChart: PreactComponent = ({ primary, secondary, className = '' }) => { - let networkChart: ChartJsChart<'line', DataPoint[]> | undefined; - - onClient(() => { - document.addEventListener('metrics:update', (ev) => { - if (!(ev instanceof CustomEvent) || !('snaps' in ev.detail)) { - throw new Error('received invalid metrics event'); - } - const { snaps } = (ev as MetricsEvent).detail; - renderNetworkChart(snaps); - }); - }); function toRateSeries( series: number[], @@ -38,7 +26,7 @@ export const NetworkChart: PreactComponent = ({ primary, secondary, class }); } - function renderNetworkChart(snaps: MetricsSnapshot[]): void { + function updateChart(chart: ChartJsChart<'line', DataPoint[]>, snaps: MetricsSnapshot[]): void { if (snaps.length < 2) { return; } @@ -52,48 +40,42 @@ export const NetworkChart: PreactComponent = ({ primary, secondary, class const dataRx: DataPoint[] = toRateSeries(rxSeries, times); const dataTx: DataPoint[] = toRateSeries(txSeries, times); - const datasets: ChartDataset<'line', DataPoint[]>[] = [ - { label: 'bytes received', data: dataRx, backgroundColor: secondary, borderColor: secondary }, - { label: 'bytes sent', data: dataTx, backgroundColor: primary, borderColor: primary } - ]; - - if (networkChart) { - networkChart.data.datasets[0].data = datasets[0].data; - networkChart.data.datasets[1].data = datasets[1].data; - networkChart.update(); - return; - } - - const el: HTMLCanvasElement | null = document.querySelector('#networkChart'); - if (!el) { - return; - } - - networkChart = new ChartJsChart<'line', DataPoint[]>(el, { - type: 'line', - data: { datasets }, - options: { - scales: { - x: { - type: 'time', - time: { unit: 'second', displayFormats: { second: 'HH:mm:ss' } }, - grid: { display: false } - }, - y: { - beginAtZero: true, - ticks: { - callback: v => typeof v === 'number' ? `${(v / 1000).toFixed(1)} KB` : v - } - } - }, - plugins: { legend: { position: 'top' } } - } - }); + chart.data.datasets[0].data = dataRx; + chart.data.datasets[1].data = dataTx; + chart.update(); } return ( - <> - - + typeof v === 'number' ? `${(v / 1000).toFixed(1)} KB` : v + } + } + }, + plugins: { legend: { position: 'top' } } + } + }} + updateChart={updateChart} + /> ); }; \ No newline at end of file diff --git a/sandbox/src/templates/components/request-duration-chart.tsx b/sandbox/src/templates/components/request-duration-chart.tsx index e66dc13..3172bb6 100644 --- a/sandbox/src/templates/components/request-duration-chart.tsx +++ b/sandbox/src/templates/components/request-duration-chart.tsx @@ -1,8 +1,7 @@ -import { Chart as ChartJsChart } from 'chart.js?client'; -import { Metric, MetricsSnapshot, onClient, PreactComponent } from 'zibri'; +import type { Chart as ChartJsChart } from 'chart.js'; +import { Metric, MetricsSnapshot, PreactComponent } from 'zibri'; import { Chart } from './chart'; -import { MetricsEvent } from '../pages/metrics'; type Props = { className?: string, @@ -10,19 +9,8 @@ type Props = { }; export const RequestDurationChart: PreactComponent = ({ className = '', secondary }) => { - let requestDurationChart: ChartJsChart | undefined; - onClient(() => { - document.addEventListener('metrics:update', (ev) => { - if (!(ev instanceof CustomEvent) || !('snaps' in ev.detail)) { - throw new Error('received invalid metrics event'); - } - const { snaps } = (ev as MetricsEvent).detail; - renderRequestDurationChart(snaps); - }); - }); - - function renderRequestDurationChart(snaps: MetricsSnapshot[]): void { + function updateChart(chart: ChartJsChart, snaps: MetricsSnapshot[]): void { if (!snaps.length) { return; } @@ -57,27 +45,21 @@ export const RequestDurationChart: PreactComponent = ({ className = '', s { labels: [], values: [] } ); - if (requestDurationChart) { - requestDurationChart.data.labels = hist.labels; - requestDurationChart.data.datasets[0].data = hist.values; - requestDurationChart.update(); - return; - } - - const el: HTMLCanvasElement | null = document.querySelector('#requestDurationChart'); - if (!el) { - return; - } - // render latency histogram - requestDurationChart = new ChartJsChart(el, { - type: 'bar', - data: { labels: hist.labels, datasets: [{ label: 'Count', data: hist.values, backgroundColor: secondary }] } - }); + chart.data.labels = hist.labels; + chart.data.datasets[0].data = hist.values; + chart.update(); } return ( - <> - - + ); }; \ No newline at end of file diff --git a/sandbox/src/templates/components/requests-per-second-chart.tsx b/sandbox/src/templates/components/requests-per-second-chart.tsx index 4493e8a..d91ed66 100644 --- a/sandbox/src/templates/components/requests-per-second-chart.tsx +++ b/sandbox/src/templates/components/requests-per-second-chart.tsx @@ -1,8 +1,7 @@ -import { ChartDataset, Chart as ChartJsChart } from 'chart.js?client'; -import { Metric, MetricsSnapshot, onClient, PreactComponent } from 'zibri'; +import type { Chart as ChartJsChart } from 'chart.js'; +import { Metric, MetricsSnapshot, PreactComponent } from 'zibri'; import { Chart } from './chart'; -import { MetricsEvent } from '../pages/metrics'; type Props = { secondary: string, @@ -14,17 +13,6 @@ type StatusClass = 'success' | 'client' | 'server'; type DataPoint = { x: Date, y: number }; export const RequestsPerSecondChart: PreactComponent = ({ secondary, className = '' }) => { - let rpsChart: ChartJsChart<'line', DataPoint[]> | undefined; - - onClient(() => { - document.addEventListener('metrics:update', (ev) => { - if (!(ev instanceof CustomEvent) || !('snaps' in ev.detail)) { - throw new Error('received invalid metrics event'); - } - const { snaps } = (ev as MetricsEvent).detail; - renderRequestsPerSecondChart(snaps); - }); - }); function sumForStatus(statusClass: StatusClass, metrics: Metric[]): number { return metrics.filter(m => { @@ -48,7 +36,7 @@ export const RequestsPerSecondChart: PreactComponent = ({ secondary, clas }).reduce((acc, m) => acc + m.value, 0); } - function renderRequestsPerSecondChart(snaps: MetricsSnapshot[]): void { + function updateChart(chart: ChartJsChart<'line', DataPoint[]>, snaps: MetricsSnapshot[]): void { const seriesSuccess: DataPoint[] = []; const seriesClientError: DataPoint[] = []; const seriesServerError: DataPoint[] = []; @@ -86,88 +74,81 @@ export const RequestsPerSecondChart: PreactComponent = ({ secondary, clas }; }); - const datasets: ChartDataset<'line', DataPoint[]>[] = [ - { - label: 'Event Loop Lag', - data: lagSeries, - fill: false, - yAxisID: 'y1', - backgroundColor: 'purple', - borderColor: 'purple' - }, - { - label: 'Server Error', - data: seriesServerError, - fill: true, - backgroundColor: 'red', - borderColor: 'red' - }, - { - label: 'Client Error', - data: seriesClientError, - fill: true, - backgroundColor: secondary, - borderColor: secondary - }, - { - label: 'Success', - data: seriesSuccess, - fill: true, - backgroundColor: 'green', - borderColor: 'green' - } - ]; - - if (rpsChart) { - rpsChart.data.datasets[0].data = datasets[0].data; - rpsChart.data.datasets[1].data = datasets[1].data; - rpsChart.data.datasets[2].data = datasets[2].data; - rpsChart.data.datasets[3].data = datasets[3].data; - rpsChart.update(); - return; - } - - const el: HTMLCanvasElement | null = document.querySelector('#rpsChart'); - if (!el) { - return; - } + chart.data.datasets[0].data = lagSeries; + chart.data.datasets[1].data = seriesServerError; + chart.data.datasets[2].data = seriesClientError; + chart.data.datasets[3].data = seriesSuccess; + chart.update(); + } - // render RPS chart - rpsChart = new ChartJsChart<'line', DataPoint[]>(el, { - type: 'line', - data: { datasets }, - options: { - scales: { - x: { - type: 'time', - time: { - unit: 'second', - displayFormats: { - second: 'HH:mm:ss' - } + return ( + typeof v === 'number' ? `${v.toFixed(1)} ms` : v } + { + label: 'Client Error', + data: [], + fill: true, + backgroundColor: secondary, + borderColor: secondary + }, + { + label: 'Success', + data: [], + fill: true, + backgroundColor: 'green', + borderColor: 'green' + } + ] + }, + options: { + scales: { + x: { + type: 'time', + time: { + unit: 'second', + displayFormats: { + second: 'HH:mm:ss' + } + }, + ticks: { stepSize: 5, source: 'auto' }, + grid: { display: false } + }, + y: { + stacked: true, + beginAtZero: true, + title: { display: true, text: 'Requests/sec' }, + grid: { display: true } + }, + y1: { + position: 'right', + title: { display: true, text: 'Lag (ms)' }, + ticks: { callback: (v): string => typeof v === 'number' ? `${v.toFixed(1)} ms` : v } + } } } - } - }); - } - - return ( - <> - - + }} + updateChart={updateChart} + /> ); }; \ No newline at end of file diff --git a/sandbox/src/templates/components/resource-usage-chart.tsx b/sandbox/src/templates/components/resource-usage-chart.tsx index dc587c7..1e05594 100644 --- a/sandbox/src/templates/components/resource-usage-chart.tsx +++ b/sandbox/src/templates/components/resource-usage-chart.tsx @@ -1,8 +1,7 @@ -import { Chart as ChartJsChart } from 'chart.js?client'; -import { Metric, MetricsSnapshot, onClient, PreactComponent } from 'zibri'; +import type { Chart as ChartJsChart } from 'chart.js'; +import { Metric, MetricsSnapshot, PreactComponent } from 'zibri'; import { Chart } from './chart'; -import { MetricsEvent } from '../pages/metrics'; type Props = { primary: string, @@ -13,20 +12,9 @@ type Props = { type DataPoint = { x: Date, y: number }; export const ResourceUsageChart: PreactComponent = ({ primary, secondary, className = '' }) => { - let resourceUsageChart: ChartJsChart<'line', DataPoint[]> | undefined; - onClient(() => { - document.addEventListener('metrics:update', (ev) => { - if (!(ev instanceof CustomEvent) || !('snaps' in ev.detail)) { - throw new Error('received invalid metrics event'); - } - const { snaps } = (ev as MetricsEvent).detail; - renderResourceUsageChart(snaps); - }); - }); - - function renderResourceUsageChart(snaps: MetricsSnapshot[]): void { - const data: DataPoint[] = snaps.map(snap => { + function updateChart(chart: ChartJsChart<'line', DataPoint[]>, snaps: MetricsSnapshot[]): void { + const ramSeries: DataPoint[] = snaps.map(snap => { const mem: Metric | undefined = snap.metrics.find(m => m.name === 'process_resident_memory_bytes'); return { x: new Date(snap.timestamp), @@ -51,71 +39,67 @@ export const ResourceUsageChart: PreactComponent = ({ primary, secondary, return { x: new Date(t * 1000), y: pct }; }); - if (resourceUsageChart) { - resourceUsageChart.data.datasets[0].data = data; - resourceUsageChart.data.datasets[1].data = cpuSeries; - resourceUsageChart.update(); - return; - } - - const el: HTMLCanvasElement | null = document.querySelector('#resourceUsageChart'); - if (!el) { - return; - } + chart.data.datasets[0].data = ramSeries; + chart.data.datasets[1].data = cpuSeries; + chart.update(); + } - resourceUsageChart = new ChartJsChart(el, { - type: 'line', - data: { - datasets: [ - { - label: 'RAM (MB)', - data, - yAxisID: 'y', - borderColor: secondary, - backgroundColor: secondary - }, - { - label: 'CPU (%)', - data: cpuSeries, - yAxisID: 'yCPU', - borderColor: primary, - backgroundColor: primary - } - ] - }, - options: { - scales: { - x: { - type: 'time', - time: { - unit: 'second', - displayFormats: { - second: 'HH:mm:ss' - } + return ( + `${v} MB` + { + label: 'CPU (%)', + data: [], + yAxisID: 'yCPU', + borderColor: primary, + backgroundColor: primary + } + ] + }, + options: { + scales: { + x: { + type: 'time', + time: { + unit: 'second', + displayFormats: { + second: 'HH:mm:ss' + } + }, + grid: { display: false } + }, + y: { + ticks: { + callback: v => `${v} MB` + }, + beginAtZero: true }, - beginAtZero: true - }, - yCPU: { - position: 'right', - grid: { drawOnChartArea: false }, // don't duplicate grid lines - ticks: { - callback: v => `${v}%` + yCPU: { + position: 'right', + grid: { drawOnChartArea: false }, // don't duplicate grid lines + min: 0, + max: 100, + ticks: { + callback: v => `${v}%` + } } } } - } - }); - } - - return ( - <> - - + }} + updateChart={updateChart} + /> ); }; \ No newline at end of file diff --git a/sandbox/src/templates/components/stat-card.tsx b/sandbox/src/templates/components/stat-card.tsx new file mode 100644 index 0000000..249a94c --- /dev/null +++ b/sandbox/src/templates/components/stat-card.tsx @@ -0,0 +1,26 @@ +// stat-card.tsx +import { ComponentChildren } from 'preact'; +import { PreactComponent } from 'zibri'; + +import { Card } from './card'; +import { Heading } from './heading'; + +type Props = { + title: string, + id: string, + unit?: string, + className?: string, + children?: ComponentChildren +}; + +export const StatCard: PreactComponent = ({ title, id, unit = '', className = '', children = '...' }) => { + return ( + + {title} +
+ {children} + {unit && {unit}} +
+
+ ); +}; \ No newline at end of file diff --git a/sandbox/src/templates/components/tab-bar.tsx b/sandbox/src/templates/components/tab-bar.tsx new file mode 100644 index 0000000..2d7a3f0 --- /dev/null +++ b/sandbox/src/templates/components/tab-bar.tsx @@ -0,0 +1,65 @@ +import { ComponentChildren } from 'preact'; +import { onClient, PreactComponent } from 'zibri'; + +type Tab = { + id: string, + label: string +}; + +type Props = { + tabs: Tab[], + currentTabId?: string, + children?: ComponentChildren, + className?: string +}; + +export const TabBar: PreactComponent = ({ tabs, children, currentTabId: initialCurrentTabId, className = '' }) => { + let currentTabId: string = initialCurrentTabId ?? tabs.at(0)?.id ?? ''; + + onClient(() => changeTab(currentTabId)); + + function changeTab(tabId: string): void { + const element: HTMLElement | null = document.getElementById(tabId); + if (!element) { + throw new Error(`TabPanel with id "${tabId}" could not be found`); + } + const buttonElement: HTMLElement | null = document.getElementById(`button-${tabId}`); + if (!buttonElement) { + throw new Error(`Button with id "button-${tabId}" could not be found`); + } + + for (const tab of tabs) { + const element: HTMLElement | null = document.getElementById(tab.id); + if (!element) { + throw new Error(`TabPanel with id "${tab.id}" could not be found`); + } + const buttonElement: HTMLElement | null = document.getElementById(`button-${tab.id}`); + if (!buttonElement) { + throw new Error(`Button with id "button-${tab.id}" could not be found`); + } + buttonElement.classList.remove('bg-secondary', 'border-secondary'); + element.style.display = 'none'; + } + + currentTabId = tabId; + buttonElement.classList.add('bg-secondary', 'border-secondary'); + element.style.display = 'block'; + } + + return ( +
+
+ {tabs.map(tab => )} +
+
+ {children} +
+
+ ); +}; \ No newline at end of file diff --git a/sandbox/src/templates/components/tab-item.tsx b/sandbox/src/templates/components/tab-item.tsx new file mode 100644 index 0000000..cf018ad --- /dev/null +++ b/sandbox/src/templates/components/tab-item.tsx @@ -0,0 +1,16 @@ +import { ComponentChildren } from 'preact'; +import { PreactComponent } from 'zibri'; + +type Props = { + id: string, + children: ComponentChildren, + className?: string +}; + +export const TabItem: PreactComponent = ({ id, className = '', children }) => { + return ( +
+ {children} +
+ ); +}; \ No newline at end of file diff --git a/sandbox/src/templates/emails/log.tsx b/sandbox/src/templates/emails/log.tsx index 62d1f08..249b2fa 100644 --- a/sandbox/src/templates/emails/log.tsx +++ b/sandbox/src/templates/emails/log.tsx @@ -49,14 +49,14 @@ export const LogEmail: LogEmailTemplate = ({ log }) => { - + {log.context.request && <> - + @@ -64,11 +64,11 @@ export const LogEmail: LogEmailTemplate = ({ log }) => { } - {log.error && <> + {log.context.error && <> - - - + + + } diff --git a/sandbox/src/templates/pages/mailing-list-preferences.tsx b/sandbox/src/templates/pages/mailing-list-preferences.tsx index b4124c5..42f14af 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 }), @@ -129,7 +129,10 @@ export const MailingListPreferencesPage: MailingListPreferencesPageTemplate = ({ )} -
+
diff --git a/sandbox/src/templates/pages/metrics.tsx b/sandbox/src/templates/pages/metrics.tsx index 89877ff..dad51c8 100644 --- a/sandbox/src/templates/pages/metrics.tsx +++ b/sandbox/src/templates/pages/metrics.tsx @@ -2,12 +2,21 @@ import { Chart } from 'chart.js?client'; import { MetricsSnapshot, onClient, PreactComponent } from 'zibri'; import { BasePage } from '../components/base-page'; +import { CacheDetailSection } from '../components/cache-details-section'; +import { CacheHitRateStatCard } from '../components/cache-hit-rate-stat-card'; +import { CacheInFlightStatCard } from '../components/cache-inflight-stat-card'; +import { CacheOverviewChart } from '../components/cache-overview-chart'; +import { CacheSizeStatCard } from '../components/cache-size-stat-card'; +import { Checkbox } from '../components/checkbox'; import { Heading } from '../components/heading'; import { MetricsStatus } from '../components/metrics-status'; import { NetworkChart } from '../components/network-chart'; import { RequestDurationChart } from '../components/request-duration-chart'; import { RequestsPerSecondChart } from '../components/requests-per-second-chart'; import { ResourceUsageChart } from '../components/resource-usage-chart'; +import { StatCard } from '../components/stat-card'; +import { TabBar } from '../components/tab-bar'; +import { TabItem } from '../components/tab-item'; export type MetricsEvent = CustomEvent<{ snaps: MetricsSnapshot[] @@ -16,10 +25,11 @@ export type MetricsEvent = CustomEvent<{ type Props = { version: string, primary: string, - secondary: string + secondary: string, + cacheNames: string[] }; -export const MetricsPage: PreactComponent = ({ version, primary, secondary }) => { +export const MetricsPage: PreactComponent = ({ version, primary, secondary, cacheNames }) => { Chart.defaults.color = 'whitesmoke'; Chart.defaults.borderColor = 'whitesmoke'; Chart.defaults.scale.grid.color = 'rgba(200, 200, 200, 0.3)'; @@ -27,13 +37,38 @@ export const MetricsPage: PreactComponent = ({ version, primary, secondar let snaps: MetricsSnapshot[] = []; let automaticReload: boolean = true; + const tabs: string[] = ['overview', 'caches']; + onClient(() => { window.addEventListener('load', () => { + activateTab('overview'); + + for (const id of tabs) { + const btn: HTMLElement | null = document.querySelector(`[data-tab-btn="${id}"]`); + btn?.addEventListener('click', () => activateTab(id)); + } + void loadSnapshots(); setInterval(() => void loadSnapshots(), 1000); }); }); + function activateTab(activeId: string): void { + for (const id of tabs) { + const panel: HTMLElement | null = document.querySelector(`[data-tab-panel="${id}"]`); + const btn: HTMLElement | null = document.querySelector(`[data-tab-btn="${id}"]`); + if (!panel || !btn) { + continue; + } + + const isActive: boolean = id === activeId; + panel.style.display = isActive ? '' : 'none'; + btn.classList.toggle('bg-secondary', isActive); + btn.classList.toggle('bg-dark-gray', !isActive); + btn.classList.toggle('hover:bg-secondary', !isActive); + } + } + async function loadSnapshots(): Promise { if (!automaticReload) { return; @@ -58,24 +93,43 @@ export const MetricsPage: PreactComponent = ({ version, primary, secondar className="flex flex-col gap-4 py-8" > Metrics -
-
-
- automaticReload = !automaticReload} - version={version} - className="flex-1" - > - - -
- -
-
- - -
+ automaticReload = !automaticReload} checked={automaticReload} + /> +
+ + +
+
+ + + +
+ +
+
+ + +
+
+ +
+
+ +
+ {cacheNames.length} + + + +
+
+ +
+ +
+
diff --git a/src/__testing__/constants.ts b/src/__testing__/constants.ts index 2c47bd5..da2fa39 100644 --- a/src/__testing__/constants.ts +++ b/src/__testing__/constants.ts @@ -2,8 +2,14 @@ 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 = () => {}; -export const noOpAsync: () => Promise = async () => {}; \ No newline at end of file +export const noOpAsync: () => Promise = async () => {}; + +export async function flushMicrotasks(): Promise { + await Promise.resolve(); +} \ No newline at end of file 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..6c8aee7 100644 --- a/src/__testing__/test-server/create-test-data-source.function.ts +++ b/src/__testing__/test-server/create-test-data-source.function.ts @@ -1,4 +1,7 @@ import { OtpCredentials } from '../../auth/2fa/methods/otp/otp-credentials.model'; +import { EncryptionKey } from '../../auth/encryption/encryption-key.model'; +import { EncryptionStrategyEntity } from '../../auth/encryption/strategies/encryption-strategy-entity.model'; +import { HashStrategyEntity } from '../../auth/hash/strategies/hash-strategy-entity.model'; import { PasswordResetToken } from '../../auth/models/password-reset-token.model'; import { JwtCredentials } from '../../auth/strategies/jwt/jwt-credentials.model'; import { JwtRefreshToken } from '../../auth/strategies/jwt/jwt-refresh-token.model'; @@ -10,6 +13,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 +45,12 @@ export const defaultTestServerEntities: Newable[] = [ JwtRefreshToken, JwtCredentials, OtpCredentials, - ThreadJobEntity + ThreadJobEntity, + Event, + EventSubscriberRun, + HashStrategyEntity, + EncryptionKey, + EncryptionStrategyEntity ]; export function createTestDataSource({ diff --git a/src/__testing__/test-server/providers.ts b/src/__testing__/test-server/providers.ts index b2bc56b..ca10653 100644 --- a/src/__testing__/test-server/providers.ts +++ b/src/__testing__/test-server/providers.ts @@ -1,3 +1,6 @@ +import { randomBytes } from 'node:crypto'; + +import { AesGcmEncryptionStrategy } from '../../auth/encryption/strategies/aes-gcm.encryption-strategy'; import { PasswordResetEmailTemplate } from '../../auth/strategies/jwt/jwt-auth.controller'; import { CronServiceInterface } from '../../cron/cron-service.interface'; import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; @@ -15,11 +18,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 }), @@ -38,6 +41,13 @@ export const defaultTestServerProviders: DiProvider[] = [ }; } }), + defineProvider({ + token: ZIBRI_DI_TOKENS.ENCRYPTION_MASTER_OPTIONS, + useValue: { + currentMasterStrategy: new AesGcmEncryptionStrategy(), + currentMasterKey: { id: 'mk1', value: randomBytes(32) } + } + }), defineProvider({ token: ZIBRI_DI_TOKENS.CRON_SERVICE, useFactory: () => { 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/__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..e51c9cb 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 ffb380e..30efad4 100644 --- a/src/application.ts +++ b/src/application.ts @@ -1,9 +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'; @@ -27,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 { FsUtilities } from './utilities/fs.utilities'; import { Ms } from './utilities/ms'; +import { NumberUtilities } from './utilities/number.utilities'; 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: NumberUtilities.multiply(Ms.YEAR, 2).dividedBy(1000) + .toNumber(), + includeSubDomains: true, + preload: false +}; + /** * A os signal that triggers the shutdown of a Zibri application. */ @@ -80,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); @@ -130,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) { @@ -168,6 +212,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 +297,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 +315,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 +333,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( @@ -346,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: [], @@ -353,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/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..5fb136b 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. @@ -41,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); @@ -53,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); } } } @@ -119,15 +121,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 +202,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 +231,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 +246,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 +263,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 +280,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 +298,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 +314,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 +325,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/encryption/encryption-key.model.ts b/src/auth/encryption/encryption-key.model.ts new file mode 100644 index 0000000..ecf7bae --- /dev/null +++ b/src/auth/encryption/encryption-key.model.ts @@ -0,0 +1,51 @@ +import { type EncryptionString } from './encryption.utilities'; +import { EncryptionStrategyEntity } from './strategies/encryption-strategy-entity.model'; +import { BaseEntity } from '../../entity/base-entity.model'; +import { Entity } from '../../entity/decorators/entity.decorator'; +import { Property } from '../../entity/decorators/property.decorator'; +import { DeepPartial } from '../../types/deep-partial.type'; +import { ExcludeStrict } from '../../types/exclude-strict.type'; +import { OmitStrict } from '../../types/omit-strict.type'; + +/** + * The status that an encryption key can have. + */ +export enum EncryptionKeyStatus { + ACTIVE = 'ACTIVE', + DEFAULT = 'DEFAULT', + DEPRECATED = 'DEPRECATED' +} + +/** + * A key used for encrypting data. + */ +@Entity({ allowOrphan: true }) +export class EncryptionKey extends BaseEntity { + /** + * The actual key value, stored encrypted. + */ + @Property.string() + value!: EncryptionString; + /** + * The encryption strategy that this key belongs to. + */ + @Property.manyToOne({ target: () => EncryptionStrategyEntity, inverseSide: 'keys' }) + strategy!: EncryptionStrategyEntity; + /** + * The status of the key. + */ + @Property.string({ enum: EncryptionKeyStatus }) + status!: EncryptionKeyStatus; +} + +/** + * The data for creating a new key. + */ +export type EncryptionKeyCreateData = OmitStrict + & DeepPartial> + & { + /** + * The status of the key. + */ + status: ExcludeStrict + }; \ No newline at end of file diff --git a/src/auth/encryption/encryption-master-options.model.ts b/src/auth/encryption/encryption-master-options.model.ts new file mode 100644 index 0000000..3fc6cd2 --- /dev/null +++ b/src/auth/encryption/encryption-master-options.model.ts @@ -0,0 +1,41 @@ +import { BaseDecryptOptions, BaseEncryptOptions, EncryptionStrategyInterface } from './strategies/encryption-strategy.interface'; + +/** + * Definition of the key used to encrypt encryption keys. + */ +export type EncryptionMasterKey = { + /** + * The id of the key. + */ + id: string, + /** + * The actual value. + */ + value: TKey +}; + +/** + * Options on how encryption keys should be encrypted. + */ +export type EncryptionMasterOptions< + TKey, + TEncryptOptions extends BaseEncryptOptions = BaseEncryptOptions, + TDecryptOptions extends BaseDecryptOptions = BaseEncryptOptions +> = { + /** + * The current master key for encrypting encryption keys. + */ + currentMasterKey: EncryptionMasterKey, + /** + * The strategy that is used to encrypt encryption keys. + */ + currentMasterStrategy: EncryptionStrategyInterface, + /** + * Any old master keys. + */ + oldMasterKeys?: EncryptionMasterKey[], + /** + * Any old master strategies. + */ + oldMasterStrategies?: EncryptionStrategyInterface[] +}; \ No newline at end of file diff --git a/src/auth/encryption/encryption-service.interface.ts b/src/auth/encryption/encryption-service.interface.ts new file mode 100644 index 0000000..daf064e --- /dev/null +++ b/src/auth/encryption/encryption-service.interface.ts @@ -0,0 +1,87 @@ +import { EncryptionKey, EncryptionKeyCreateData } from './encryption-key.model'; +import { EncryptionString } from './encryption.utilities'; +import { BaseEncryptOptions, EncryptionStrategyInterface } from './strategies/encryption-strategy.interface'; +import { BaseRepositoryOptions } from '../../data-source/models/options/base-repository-options.model'; +import { Where } from '../../data-source/models/where/where-filter.model'; +import { AnyObject } from '../../entity/any-object.model'; +import { Newable } from '../../types/newable.type'; +import { OmitStrict } from '../../types/omit-strict.type'; + +/** + * Options for encrypting a value. + */ +export type EncryptOptions> = { + /** + * The strategy to use. + */ + strategy: Newable>, + /** + * Additional options for that strategy. + */ + strategyOptions?: Partial> +}; + +/** + * Additional options for deleting encryption keys. + */ +export type DeleteEncryptionKeyOptions = BaseRepositoryOptions & { + /** + * Whether or not it is allowed to delete the default key. + */ + allowDefault?: boolean +}; + +/** + * Interface for an encryption service. + */ +export interface EncryptionServiceInterface { + /** + * Encrypts the given value using the provided options. + */ + encrypt: >( + value: string, + options?: EncryptOptions + ) => EncryptionString | Promise, + /** + * Decrypts the given value using the provided options. + */ + decrypt: ( + value: EncryptionString, + options?: TDecryptOptions + ) => string | Promise, + /** + * Creates a new encryption key for the given strategy. + */ + createKey: ( + strategy: Newable>, + data: OmitStrict, + options?: BaseRepositoryOptions + ) => EncryptionKey | Promise, + /** + * Deletes the key with the given id. + */ + deleteKey: (keyId: string, options?: DeleteEncryptionKeyOptions) => void | Promise, + /** + * Deletes all keys that match the given where clause. + */ + deleteAllKeys: (where: Where, options?: DeleteEncryptionKeyOptions) => void | Promise, + /** + * Marks the key with the given id as the default key for the given strategy. + */ + markKeyAsDefaultForStrategy: ( + keyId: string, + strategy: Newable>, + options?: BaseRepositoryOptions + ) => void | Promise, + /** + * Decrypts the key of the given strategy. + */ + decryptKey: ( + strategy: Newable>, + encryptedKey: EncryptionString + ) => Promise, + /** + * Checks whether or not the given encryption string should be re-encrypted. + */ + needsReEncryption: (encrypted: EncryptionString) => boolean | Promise +} \ No newline at end of file diff --git a/src/auth/encryption/encryption.service.test.ts b/src/auth/encryption/encryption.service.test.ts new file mode 100644 index 0000000..4dd27d8 --- /dev/null +++ b/src/auth/encryption/encryption.service.test.ts @@ -0,0 +1,67 @@ +import { afterAll, beforeAll, describe, expect, test } from '@jest/globals'; + +import { EncryptionKey } from './encryption-key.model'; +import { EncryptionService } from './encryption.service'; +import { EncryptionString } from './encryption.utilities'; +import { AesGcmEncryptionStrategy } from './strategies/aes-gcm.encryption-strategy'; +import { EncryptionStrategyEntity } from './strategies/encryption-strategy-entity.model'; +import { StartedTestServer, startTestServer } from '../../__testing__/test-server/start-test-server.function'; +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'; + +describe('EncryptionService', () => { + let server: StartedTestServer; + let encryptionService: EncryptionService; + let strategyRepository: Repository; + let keyRepository: Repository; + + beforeAll(async () => { + server = await startTestServer(); + strategyRepository = inject(repositoryTokenFor(EncryptionStrategyEntity)); + keyRepository = inject(repositoryTokenFor(EncryptionKey)); + encryptionService = inject(ZIBRI_DI_TOKENS.ENCRYPTION_SERVICE) as EncryptionService; + }, 15000); + + afterAll(async () => { + await server?.shutdown(); + }); + + test('creates strategy metadata and key metadata on first encrypt', async () => { + const strategiesBeforeEncryption: EncryptionStrategyEntity[] = await strategyRepository.findAll(); + expect(strategiesBeforeEncryption.length).toBe(0); + + const encrypted: string = await encryptionService.encrypt('hello'); + expect(encrypted).toBeTruthy(); + + const strategies: EncryptionStrategyEntity[] = await strategyRepository.findAll(); + const keys: EncryptionKey[] = await keyRepository.findAll(); + + expect(strategies.length).toBeGreaterThan(0); + expect(keys.length).toBeGreaterThan(0); + }); + + test('decrypts an encrypted value', async () => { + const encrypted: EncryptionString = await encryptionService.encrypt('secret'); + + await expect(encryptionService.decrypt(encrypted)).resolves.toBe('secret'); + }); + + test('does not duplicate key metadata on repeated encryptions with same strategy', async () => { + await encryptionService.encrypt('first'); + await encryptionService.encrypt('second'); + + const keys: EncryptionKey[] = await keyRepository.findAll(); + expect(keys).toHaveLength(1); + }); + + test('fails with invalid aad', async () => { + const encrypted: EncryptionString = await encryptionService.encrypt( + 'secret', + { strategy: AesGcmEncryptionStrategy, strategyOptions: { aad: 'userId:42' } } + ); + await expect(encryptionService.decrypt(encrypted)).rejects.toThrow(); + await expect(encryptionService.decrypt(encrypted, { aad: 'userId:42' })).resolves.toBe('secret'); + }); +}); \ No newline at end of file diff --git a/src/auth/encryption/encryption.service.ts b/src/auth/encryption/encryption.service.ts new file mode 100644 index 0000000..2eeead5 --- /dev/null +++ b/src/auth/encryption/encryption.service.ts @@ -0,0 +1,695 @@ +import { EncryptionKeyCreateData, EncryptionKeyStatus, EncryptionKey } from './encryption-key.model'; +import { type EncryptionMasterOptions, EncryptionMasterKey } from './encryption-master-options.model'; +import { type DeleteEncryptionKeyOptions, EncryptionServiceInterface, EncryptOptions } from './encryption-service.interface'; +import { EncryptionContent, type EncryptionString, EncryptionUtilities } from './encryption.utilities'; +import { EncryptionStrategyEntity, type EncryptionStrategyEntityCreateData, EncryptionStrategyStatus } from './strategies/encryption-strategy-entity.model'; +import { BaseDecryptOptions, BaseEncryptOptions, EncryptionStrategyInterface } from './strategies/encryption-strategy.interface'; +import { WriteThroughReadThroughCache } from '../../caching/cache/read-through/write-through-read-through.cache'; +import { type CacheServiceInterface } from '../../caching/cache-service.interface'; +import { CacheDelete } from '../../caching/decorators/cache-delete.decorator'; +import { CacheWrite } from '../../caching/decorators/cache-write.decorator'; +import { Cache } from '../../caching/decorators/cache.decorator'; +import { Cached } from '../../caching/decorators/cached.decorator'; +import { InMemoryCacheStore } from '../../caching/store/in-memory.cache-store'; +import { type BaseRepositoryOptions } from '../../data-source/models/options/base-repository-options.model'; +import { Where } from '../../data-source/models/where/where-filter.model'; +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 { Injectable } from '../../di/decorators/injectable.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 { register } from '../../di/register.function'; +import { AnyObject } from '../../entity/any-object.model'; +import { NotFoundError } from '../../error-handling/errors/not-found.error'; +import { OnAppInit } from '../../global/on-app-init.interface'; +import { type LoggerInterface } from '../../logging/logger.interface'; +import { type MetricsServiceInterface } from '../../metrics/metrics-service.interface'; +import { type DeepPartial } from '../../types/deep-partial.type'; +import { type Newable } from '../../types/newable.type'; +import { type OmitStrict } from '../../types/omit-strict.type'; +import { PromiseUtilities } from '../../utilities/promise.utilities'; + +// eslint-disable-next-line jsdoc/require-jsdoc +type StrategyAndEntity = { + // eslint-disable-next-line jsdoc/require-jsdoc + strategy: EncryptionStrategyInterface, + // eslint-disable-next-line jsdoc/require-jsdoc + entity: EncryptionStrategyEntity | undefined +}; + +const INIT_ERROR_MESSAGE: string = 'Error initializing encryption service.'; + +@Cache() +// eslint-disable-next-line jsdoc/require-jsdoc +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(), []); + } +} + +/** + * Default encryption service implementation of Zibri. + */ +@Injectable({ register: 'onUse' }) +export class EncryptionService implements EncryptionServiceInterface, OnAppInit { + private hasCreatedInitialDefaultStrategy: boolean = false; + private readonly strategies: EncryptionStrategyInterface[] = []; + private readonly strategyEntities: EncryptionStrategyEntity[] = []; + private readonly options: EncryptionMasterOptions; + + constructor( + @InjectRepository(EncryptionStrategyEntity) + private readonly strategyRepository: Repository, + @InjectRepository(EncryptionKey) + private readonly keyRepository: Repository, + @Inject(ZIBRI_DI_TOKENS.ENCRYPTION_STRATEGIES) + private readonly strategyClasses: Newable>[], + @Inject(ZIBRI_DI_TOKENS.ENCRYPTION_MASTER_OPTIONS) + options: EncryptionMasterOptions | undefined + ) { + if (!options) { + throw new NoProviderError(ZIBRI_DI_TOKENS.ENCRYPTION_MASTER_OPTIONS, []); + } + this.options = options; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async onAppInit(): Promise { + if (!this.strategyClasses.length) { + throw new Error('Needs to provide at least one encryption strategy.'); + } + for (const strategy of this.strategyClasses) { + register({ token: strategy, useClass: strategy }); + this.strategies.push(inject(strategy)); + } + const strategies: EncryptionStrategyEntity[] = await this.strategyRepository.findAll(); + this.strategyEntities.push(...strategies); + this.validateNoDuplicateEncryptionStrategy(); + this.validateNoMissingEncryptionStrategy(); + this.validateStrategyNamesAndVersions(); + + const keys: EncryptionKey[] = await this.keyRepository.findAll(); + this.validateNoDuplicateMasterEncryptionStrategy(); + this.validateNoMissingMasterEncryptionStrategy(keys); + this.validateMasterStrategyNamesAndVersions(); + this.validateNoDotsInMasterKeyIds(); + this.validateNoMissingMasterKeyIds(keys); + await this.reEncryptKeys(keys); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async encrypt>( + value: string, + options?: EncryptOptions + ): Promise { + if (!options) { + const strategy: StrategyAndEntity = this.findDefaultStrategy(); + if (!this.hasCreatedInitialDefaultStrategy && !strategy.entity && !this.strategyEntities.length) { + const key: EncryptionKeyCreateData = { + status: EncryptionKeyStatus.DEFAULT, + value: await this.generateEncryptedKey(strategy.strategy) + }; + const entity: EncryptionStrategyEntity = await this.createStrategyEntity({ + name: strategy.strategy.name, + version: strategy.strategy.version, + status: EncryptionStrategyStatus.DEFAULT, + keys: [key] + }); + this.hasCreatedInitialDefaultStrategy = true; + const strategyClass: Newable> = this.findStrategyClass( + strategy.strategy.name, + strategy.strategy.version + ); + const decryptedKey: TKey = await this.decryptKey(strategyClass, key.value); + return await strategy.strategy.encrypt(value, { keyId: entity.keys[0].id, key: decryptedKey }); + } + + if (!strategy.entity) { + const key: EncryptionKeyCreateData = { + status: EncryptionKeyStatus.DEFAULT, + value: await this.generateEncryptedKey(strategy.strategy) + }; + const entity: EncryptionStrategyEntity = await this.createStrategyEntity({ + name: strategy.strategy.name, + version: strategy.strategy.version, + status: EncryptionStrategyStatus.ACTIVE, + keys: [key] + }); + const strategyClass: Newable> = this.findStrategyClass( + strategy.strategy.name, + strategy.strategy.version + ); + const decryptedKey: TKey = await this.decryptKey(strategyClass, key.value); + return await strategy.strategy.encrypt(value, { keyId: entity.keys[0].id, key: decryptedKey }); + } + + const strategyClass: Newable> = this.findStrategyClass( + strategy.strategy.name, + strategy.strategy.version + ); + const key: EncryptionKey = await this.findOrCreateDefaultKeyForStrategy(strategy.entity); + const decryptedKey: TKey = await this.decryptKey(strategyClass, key.value); + return await strategy.strategy.encrypt(value, { keyId: key.id, key: decryptedKey }); + } + + const strategy: EncryptionStrategyInterface | undefined = this.strategies.find( + s => s instanceof options.strategy + ) as EncryptionStrategyInterface | undefined; + if (!strategy) { + throw new Error( + `The given strategy ${options.strategy.name} was not provided as part of ZIBRI_DI_TOKENS.ENCRYPTION_STRATEGIES` + ); + } + + if (options.strategyOptions?.keyId) { + const decryptedKey: TKey = await this.findKey(options.strategyOptions.keyId, undefined); + return await strategy.encrypt( + value, + { key: decryptedKey, keyId: options.strategyOptions.keyId, ...options.strategyOptions } + ); + } + + const entity: EncryptionStrategyEntity | undefined = this.strategyEntities.find( + s => s.name === strategy.name && s.version === strategy.version + ); + if (!entity) { + const key: EncryptionKeyCreateData = { + status: EncryptionKeyStatus.DEFAULT, + value: await this.generateEncryptedKey(strategy) + }; + const createdEntity: EncryptionStrategyEntity = await this.createStrategyEntity({ + name: strategy.name, + version: strategy.version, + status: EncryptionStrategyStatus.ACTIVE, + keys: [key] + }); + const decryptedKey: TKey = await this.decryptKey(options.strategy, key.value); + return await strategy.encrypt(value, { keyId: createdEntity.keys[0].id, key: decryptedKey, ...options.strategyOptions }); + } + + const key: EncryptionKey = await this.findOrCreateDefaultKeyForStrategy(entity); + const decryptedKey: TKey = await this.decryptKey(options.strategy, key.value); + return await strategy.encrypt(value, { key: decryptedKey, keyId: key.id, ...options.strategyOptions }); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async decrypt( + value: EncryptionString, + options?: TDecryptOptions + ): Promise { + const content: EncryptionContent = EncryptionUtilities.encryptionStringToContent(value); + const strategy: EncryptionStrategyInterface = this.findStrategy(content.strategyName, content.version); + const key: unknown = await this.findKey(content.keyId, undefined); + return await strategy.decrypt(value, { key, keyId: content.keyId, ...options ?? {} }); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async createKey( + strategyClass: Newable>, + data: OmitStrict, + options?: BaseRepositoryOptions + ): Promise { + const strategy: EncryptionStrategyInterface = inject(strategyClass); + const encryptedKey: EncryptionString = await this.generateEncryptedKey(strategy); + const entity: EncryptionStrategyEntity | undefined = this.strategyEntities.find( + s => s.name === strategy.name && s.version === strategy.version + ); + if (!entity) { + throw new NotFoundError(`Could not find ${EncryptionStrategyEntity.name}.`); + } + const currentDefaultKey: EncryptionKey | undefined = entity.keys.find(k => k.status === EncryptionKeyStatus.DEFAULT); + if (data.status === EncryptionKeyStatus.DEFAULT && currentDefaultKey) { + await this.updateKeyEntityById(currentDefaultKey.id, { status: EncryptionKeyStatus.ACTIVE }, options); + } + return await this.createKeyEntity(encryptedKey, entity, data, options); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async deleteKey( + keyId: string, + options?: DeleteEncryptionKeyOptions + ): Promise { + const key: EncryptionKey = await this.findKeyEntityById(keyId, options); + if (key.status === EncryptionKeyStatus.DEFAULT && !(options?.allowDefault ?? false)) { + throw new Error('Cannot delete the default key. Pass allowDefault: true to override.'); + } + + await this.deleteKeyEntityById(keyId, options); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async deleteAllKeys(where: Where, options: DeleteEncryptionKeyOptions = {}): Promise { + const keys: EncryptionKey[] = await this.keyRepository.findAll({ where }); + + if (!(options.allowDefault ?? false) && keys.some(k => k.status === EncryptionKeyStatus.DEFAULT)) { + throw new Error('Cannot delete a default key. Pass allowDefault: true to override.'); + } + + const transaction: Transaction = options.transaction ?? await this.keyRepository.dataSource.startTransaction(); + const ownTransaction: boolean = !options.transaction; + options.transaction = transaction; + + try { + await PromiseUtilities.allChunked(keys, k => this.deleteKeyEntityById(k.id, options)); + if (ownTransaction) { + await transaction.commit(); + } + } + catch (error) { + if (ownTransaction) { + await transaction.rollback(); + } + throw error; + } + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async markKeyAsDefaultForStrategy( + keyId: string, + strategyClass: Newable>, + options: BaseRepositoryOptions = {} + ): Promise { + const transaction: Transaction = options.transaction ?? await this.keyRepository.dataSource.startTransaction(); + const ownTransaction: boolean = !options.transaction; // track if we created it + options.transaction = transaction; + + try { + const strategy: EncryptionStrategyInterface = inject(strategyClass); + const entity: EncryptionStrategyEntity | undefined = this.strategyEntities.find( + s => s.name === strategy.name && s.version === strategy.version + ); + if (!entity) { + throw new NotFoundError(`Could not find ${EncryptionStrategyEntity.name}.`); + } + const key: EncryptionKey = await this.findKeyEntityById(keyId, options); + if (key.strategy.name !== strategy.name || key.strategy.version !== strategy.version) { + throw new Error('The key for the given id has a different strategy than the provided one'); + } + + const currentDefaultKey: EncryptionKey | undefined = entity.keys.find(k => k.status === EncryptionKeyStatus.DEFAULT); + if (currentDefaultKey) { + await this.updateKeyEntityById(currentDefaultKey.id, { status: EncryptionKeyStatus.ACTIVE }, options); + } + await this.updateKeyEntityById(keyId, { status: EncryptionKeyStatus.DEFAULT }, options); + + if (ownTransaction) { + await transaction.commit(); + } + + } + catch (error) { + if (ownTransaction) { + await transaction.rollback(); + } + throw error; + } + + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async decryptKey>( + strategy: Newable>, + encryptedKey: EncryptionString + ): Promise { + const keyBase64: string = await this.masterDecrypt(encryptedKey); + const keyBuffer: Buffer = Buffer.from(keyBase64, 'base64'); + return await inject(strategy).deserializeKey(keyBuffer); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async needsReEncryption(encrypted: EncryptionString): Promise { + const content: EncryptionContent = EncryptionUtilities.encryptionStringToContent(encrypted); + const strategy: EncryptionStrategyInterface> = this.findStrategy(content.strategyName, content.version); + const strategyEntity: EncryptionStrategyEntity | undefined = this.strategyEntities.find( + s => s.name === strategy.name && s.version === strategy.version + ); + if (!strategyEntity) { + throw new NotFoundError(`Could not find ${EncryptionStrategyEntity.name}.`); + } + const key: EncryptionKey = await this.findKeyEntityById(content.keyId, undefined); + return strategyEntity.status === EncryptionStrategyStatus.DEPRECATED || key.status === EncryptionKeyStatus.DEPRECATED; + } + + private needsMasterReEncryption(encrypted: EncryptionString): boolean { + const content: EncryptionContent = EncryptionUtilities.encryptionStringToContent(encrypted); + const allKeys: EncryptionMasterKey[] = [ + this.options.currentMasterKey, + ...this.options.oldMasterKeys ?? [] + ]; + const key: EncryptionMasterKey | undefined = allKeys.find(k => k.id === content.keyId); + if (!key) { + throw new Error(`No master key found with id "${content.keyId}"`); + } + const allStrategies: EncryptionStrategyInterface[] = [ + this.options.currentMasterStrategy, + ...this.options.oldMasterStrategies ?? [] + ]; + const strategy: EncryptionStrategyInterface | undefined = allStrategies.find( + k => k.name === content.strategyName && k.version === content.version + ); + if (!strategy) { + throw new Error(`No master strategy found for ${content.strategyName} (${content.version})`); + } + return strategy.name !== this.options.currentMasterStrategy.name + || strategy.version !== this.options.currentMasterStrategy.version + || key.id !== this.options.currentMasterKey.id; + } + + private async generateEncryptedKey(strategy: EncryptionStrategyInterface): Promise { + const rawKey: TKey = await strategy.generateRandomKey(); + const keyBytes: Buffer = await strategy.serializeKey(rawKey); + const keyBase64: string = keyBytes.toString('base64'); + return await this.masterEncrypt(keyBase64); + } + + private async findKey(keyId: string, options: BaseRepositoryOptions | undefined): Promise { + const entity: EncryptionKey = await this.findKeyEntityById(keyId, options); + const strategy: Newable> = this.findStrategyClass( + entity.strategy.name, + entity.strategy.version + ); + + return await this.decryptKey(strategy, entity.value); + } + + // eslint-disable-next-line unusedImports/no-unused-vars + @Cached(EncryptionKeyCache, (id, ..._) => id) + private async findKeyEntityById(id: string, options: BaseRepositoryOptions | undefined): Promise { + return await this.keyRepository.findById(id, { relations: ['strategy'], ...options }); + } + + // eslint-disable-next-line unusedImports/no-unused-vars + @CacheWrite(EncryptionKeyCache, (key, ..._) => key.id) + private async createKeyEntity( + encryptedValue: EncryptionString, + strategy: EncryptionStrategyEntity, + data: OmitStrict, + options: BaseRepositoryOptions | undefined + ): Promise { + const created: EncryptionKey = await this.keyRepository.create({ + value: encryptedValue, + strategy, + ...data + }, options); + const key: EncryptionKey = await this.keyRepository.findById(created.id, { relations: ['strategy'], ...options }); + this.syncStrategyEntityKey(strategy, key); + return key; + } + + // eslint-disable-next-line unusedImports/no-unused-vars + @CacheWrite(EncryptionKeyCache, (key, ..._) => key.id) + private async updateKeyEntityById( + id: string, + data: DeepPartial, + options: BaseRepositoryOptions | undefined + ): Promise { + const updated: EncryptionKey = await this.keyRepository.updateById(id, data, options); + const key: EncryptionKey = await this.keyRepository.findById(updated.id, { relations: ['strategy'], ...options }); + this.syncStrategyEntityKey(updated.strategy, key); + return key; + } + + // eslint-disable-next-line unusedImports/no-unused-vars + @CacheDelete(EncryptionKeyCache, (keyId, _) => keyId) + private async deleteKeyEntityById(id: string, options: BaseRepositoryOptions | undefined): Promise { + const strategy: EncryptionStrategyEntity | undefined = this.strategyEntities.find(s => s.keys.some(k => k.id === id)); + if (!strategy) { + throw new NotFoundError(`Could not find strategy for key with id ${id}`); + } + strategy.keys = strategy.keys.filter(k => k.id !== id); + await this.keyRepository.deleteById(id, options); + } + + private async createStrategyEntity(data: EncryptionStrategyEntityCreateData): Promise { + const res: EncryptionStrategyEntity = await this.strategyRepository.create(data); + this.strategyEntities.push(res); + return res; + } + + private syncStrategyEntityKey(strategy: EncryptionStrategyEntity, key: EncryptionKey): void { + const existingIndex: number = strategy.keys.findIndex(k => k.id === key.id); + if (existingIndex >= 0) { + strategy.keys[existingIndex] = key; + return; + } + strategy.keys.push(key); + } + + private async findOrCreateDefaultKeyForStrategy(strategy: EncryptionStrategyEntity): Promise { + return strategy.keys.find(k => k.status === EncryptionKeyStatus.DEFAULT) + ?? strategy.keys.at(0) + ?? await this.createKey(this.findStrategyClass(strategy.name, strategy.version), { status: EncryptionKeyStatus.DEFAULT }); + } + + private findStrategy< + TKey, + TEncryptOptions extends BaseEncryptOptions, + TDecryptOptions extends BaseDecryptOptions + >(name: string, version: string): EncryptionStrategyInterface { + const res: EncryptionStrategyInterface | undefined = this.strategies.find( + s => s.name === name && s.version === version + ) as EncryptionStrategyInterface | undefined; + if (!res) { + throw new Error(`No strategy found for ${name} (${version})`); + } + return res; + } + + private findStrategyClass< + TKey, + TEncryptOptions extends BaseEncryptOptions, + TDecryptOptions extends BaseDecryptOptions + >(name: string, version: string): Newable> { + const res: Newable> | undefined = this.strategyClasses.find( + s => inject(s).name === name && inject(s).version === version + ) as Newable> | undefined; + if (!res) { + throw new Error(`No strategy found for ${name} (${version})`); + } + return res; + } + + private async masterEncrypt(keyBase64: string): Promise { + const encrypted: EncryptionString = await this.options.currentMasterStrategy.encrypt( + keyBase64, + { + key: this.options.currentMasterKey.value, + keyId: this.options.currentMasterKey.id + } + ); + + const content: EncryptionContent = EncryptionUtilities.encryptionStringToContent(encrypted); + if (content.keyId !== this.options.currentMasterKey.id) { + throw new Error( + `Master key id mismatch: expected ${this.options.currentMasterKey.id}, got ${content.keyId}` + ); + } + return encrypted; + } + + private async masterDecrypt(encrypted: EncryptionString): Promise { + const content: EncryptionContent = EncryptionUtilities.encryptionStringToContent(encrypted); + const masterKey: EncryptionMasterKey | undefined = [ + this.options.currentMasterKey, + ...this.options.oldMasterKeys ?? [] + ].find(k => k.id === content.keyId); + if (!masterKey) { + throw new Error(`Could not find master encryption key with id "${content.keyId}"`); + } + const masterStrategy: EncryptionStrategyInterface | undefined = [ + this.options.currentMasterStrategy, + ...this.options.oldMasterStrategies ?? [] + ].find(s => s.name === content.strategyName && s.version === content.version); + if (!masterStrategy) { + throw new Error(`Could not find master encryption strategy ${content.strategyName} (${content.version})`); + } + return await masterStrategy.decrypt(encrypted, { key: masterKey.value, keyId: masterKey.id }); + } + + private findDefaultStrategy(): StrategyAndEntity { + const entity: EncryptionStrategyEntity | undefined = this.strategyEntities.find(s => s.status === EncryptionStrategyStatus.DEFAULT); + return entity + ? { strategy: this.findStrategy(entity.name, entity.version), entity } + : { strategy: this.strategies[0] as EncryptionStrategyInterface, entity }; + } + + private validateNoDuplicateEncryptionStrategy(): void { + const duplicateStrategies: EncryptionStrategyInterface[] = this.strategies.filter( + s => this.strategies.filter(hs => hs.name === s.name && hs.version === s.version).length > 1 + ); + if (duplicateStrategies.length) { + throw new Error( + [ + 'There are duplicate encryption strategies:', + [...new Set(duplicateStrategies)].map(s => `- ${s.name} (${s.version})`) + ].join('\n') + ); + } + } + + private validateNoDuplicateMasterEncryptionStrategy(): void { + const allMasterStrategies: EncryptionStrategyInterface[] = [ + this.options.currentMasterStrategy, + ...this.options.oldMasterStrategies ?? [] + ]; + const duplicateStrategies: EncryptionStrategyInterface[] = allMasterStrategies.filter( + s => allMasterStrategies.filter(hs => hs.name === s.name && hs.version === s.version).length > 1 + ); + if (duplicateStrategies.length) { + throw new Error( + [ + 'There are duplicate master encryption strategies:', + [...new Set(duplicateStrategies)].map(s => `- ${s.name} (${s.version})`) + ].join('\n') + ); + } + } + + private validateNoMissingEncryptionStrategy(): void { + const missingStrategies: EncryptionStrategyEntity[] = this.strategyEntities.filter( + s => !this.strategies.some(hs => hs.name === s.name && hs.version === s.version) + ); + if (missingStrategies.length) { + const message: string[] = [INIT_ERROR_MESSAGE, 'There are missing strategies:']; + for (const strategy of missingStrategies) { + message.push(` - ${strategy.name} (${strategy.version})`); + } + message.push('Did you remove them?'); + throw new Error(message.join('\n')); + } + } + + private validateNoDotsInMasterKeyIds(): void { + const allKeys: EncryptionMasterKey[] = [ + this.options.currentMasterKey, + ...this.options.oldMasterKeys ?? [] + ]; + + const keysWithDotsInId: EncryptionMasterKey[] = allKeys.filter( + s => s.id.includes('.') + ); + if (!keysWithDotsInId.length) { + return; + } + const message: string[] = [INIT_ERROR_MESSAGE, 'There are master keys that use dots in their id:']; + for (const key of keysWithDotsInId) { + message.push(` - ${key.id}`); + } + throw new Error(message.join('\n')); + } + + private validateNoMissingMasterKeyIds(keys: EncryptionKey[]): void { + const allMasterKeys: EncryptionMasterKey[] = [ + this.options.currentMasterKey, + ...this.options.oldMasterKeys ?? [] + ]; + + const missingKeyIds: string[] = keys + .map(k => EncryptionUtilities.encryptionStringToContent(k.value).keyId) + .filter(keyId => !allMasterKeys.some(k => k.id === keyId)); + + if (!missingKeyIds.length) { + return; + } + + throw new Error( + [ + INIT_ERROR_MESSAGE, + 'There are missing master key ids:', + ...missingKeyIds.map(id => ` - ${id}`) + ].join('\n') + ); + } + + private validateNoMissingMasterEncryptionStrategy(keys: EncryptionKey[]): void { + const allMasterStrategies: EncryptionStrategyInterface[] = [ + this.options.currentMasterStrategy, + ...this.options.oldMasterStrategies ?? [] + ]; + const missingStrategies: EncryptionContent[] = keys + .map(k => EncryptionUtilities.encryptionStringToContent(k.value)) + .filter( + s => !allMasterStrategies.some(hs => hs.name === s.strategyName && hs.version === s.version) + ); + if (missingStrategies.length) { + const message: string[] = [INIT_ERROR_MESSAGE, 'There are missing master strategies:']; + for (const strategy of missingStrategies) { + message.push(` - ${strategy.strategyName} (${strategy.version})`); + } + message.push('Did you remove them?'); + throw new Error(message.join('\n')); + } + } + + private validateStrategyNamesAndVersions(): void { + const strategiesWithDotsInNameOrVersion: EncryptionStrategyInterface[] = this.strategies.filter( + s => s.name.includes('.') || s.version.includes('.') + ); + if (!strategiesWithDotsInNameOrVersion.length) { + return; + } + const message: string[] = [ + INIT_ERROR_MESSAGE, + 'There are strategies that use dots in either the version or the name:' + ]; + for (const strategy of strategiesWithDotsInNameOrVersion) { + message.push(` - ${strategy.name} (${strategy.version})`); + } + throw new Error(message.join('\n')); + } + + private validateMasterStrategyNamesAndVersions(): void { + const allMasterStrategies: EncryptionStrategyInterface[] = [ + this.options.currentMasterStrategy, + ...this.options.oldMasterStrategies ?? [] + ]; + const strategiesWithDotsInNameOrVersion: EncryptionStrategyInterface[] = allMasterStrategies.filter( + s => s.name.includes('.') || s.version.includes('.') + ); + if (!strategiesWithDotsInNameOrVersion.length) { + return; + } + const message: string[] = [ + INIT_ERROR_MESSAGE, + 'There are master strategies that use dots in either the version or the name:' + ]; + for (const strategy of strategiesWithDotsInNameOrVersion) { + message.push(` - ${strategy.name} (${strategy.version})`); + } + throw new Error(message.join('\n')); + } + + private async reEncryptKeys(keys: EncryptionKey[]): Promise { + await PromiseUtilities.allChunked(keys, key => this.reEncryptKey(key)); + } + + private async reEncryptKey(key: EncryptionKey): Promise { + if (!this.needsMasterReEncryption(key.value)) { + return; + } + + const decrypted: string = await this.masterDecrypt(key.value); + const newEncrypted: string = await this.masterEncrypt(decrypted); + + await this.updateKeyEntityById(key.id, { value: newEncrypted }, undefined); + } +} \ No newline at end of file diff --git a/src/auth/encryption/encryption.utilities.ts b/src/auth/encryption/encryption.utilities.ts new file mode 100644 index 0000000..5835393 --- /dev/null +++ b/src/auth/encryption/encryption.utilities.ts @@ -0,0 +1,65 @@ + +/** + * A string with encrypted data stored inside of it. + */ +export type EncryptionString = string & { + // eslint-disable-next-line jsdoc/require-jsdoc + __brand: 'encryption' +}; + +/** + * The content stored inside an encryption string. + */ +export type EncryptionContent = { + /** + * The name of the encryption strategy. + */ + strategyName: string, + /** + * The version of the encryption strategy. + */ + version: string, + /** + * The id of the key that has been used. + */ + keyId: string, + /** + * The actual encrypted value. + */ + encryptedValue: string +}; + +/** + * Utilities around handling encryption. + */ +export abstract class EncryptionUtilities { + /** + * Creates an encryption string from the given content. + * @param data - The content to store inside the encryption string. + * @returns The encryption string in the form "strategyName.version.encryptedValue". + */ + static contentToEncryptionString(data: EncryptionContent): EncryptionString { + return [data.strategyName, data.version, data.keyId, data.encryptedValue].join('.') as EncryptionString; + } + /** + * Resolves the content of the given encryption string. + * @param encrypted - The encryption string to resolve the content of. + * @returns The encryption content inside the given string. + * @throws If the encryption string is invalid. + */ + static encryptionStringToContent(encrypted: EncryptionString): EncryptionContent { + const [strategyName, version, keyId, ...encryptedValueParts] = encrypted.split('.'); + const encryptedValue: string = encryptedValueParts.join('.'); + + if (!strategyName || !version || !keyId || !encryptedValue) { + throw new Error('Invalid encryption string'); + } + + return { + strategyName, + version, + keyId, + encryptedValue + }; + } +} \ No newline at end of file diff --git a/src/auth/encryption/strategies/aes-gcm.encryption-strategy.ts b/src/auth/encryption/strategies/aes-gcm.encryption-strategy.ts new file mode 100644 index 0000000..29687e8 --- /dev/null +++ b/src/auth/encryption/strategies/aes-gcm.encryption-strategy.ts @@ -0,0 +1,115 @@ +import { CipherGCM, CipherGCMTypes, createCipheriv, createDecipheriv, DecipherGCM, randomBytes } from 'node:crypto'; + +import { EncryptionContent, EncryptionString, EncryptionUtilities } from '../encryption.utilities'; +import { BaseDecryptOptions, BaseEncryptOptions, EncryptionStrategyInterface } from './encryption-strategy.interface'; + +/** + * Options for encrypting with the default aes-256-gcm encryption strategy implementation of Zibri. + */ +type AesGcmEncryptOptions = BaseEncryptOptions & { + /** + * The "Additional authenticated data" can be used to store something like a user id + * on the encrypted value that needs to be provided when decrypting. + */ + aad?: string +}; + +/** + * Options for encrypting with the default aes-256-gcm encryption strategy implementation of Zibri. + */ +type AesGcmDecryptOptions = BaseDecryptOptions & { + /** + * The "Additional authenticated data" can be used to store something like a user id + * on the encrypted value that needs to be provided when decrypting. + */ + aad?: string +}; + +/** + * Default aes-256-gcm encryption strategy implementation of Zibri. + */ +export class AesGcmEncryptionStrategy implements EncryptionStrategyInterface< + Buffer, + AesGcmEncryptOptions, + AesGcmDecryptOptions +> { + // eslint-disable-next-line jsdoc/require-jsdoc + readonly name: CipherGCMTypes = 'aes-256-gcm'; + // eslint-disable-next-line jsdoc/require-jsdoc + readonly version: string = 'v1'; + + // eslint-disable-next-line jsdoc/require-jsdoc + encrypt(value: string, options: AesGcmEncryptOptions): EncryptionString { + const iv: Buffer = randomBytes(12); + const cipher: CipherGCM = createCipheriv(this.name, options.key, iv); + + if (options.aad !== undefined) { + cipher.setAAD(Buffer.from(options.aad, 'utf8')); + } + + const encrypted: Buffer = Buffer.concat([ + cipher.update(value, 'utf8'), + cipher.final() + ]); + + const tag: Buffer = cipher.getAuthTag(); + + const encryptedValue: string = [ + iv.toString('base64'), + tag.toString('base64'), + encrypted.toString('base64') + ].join('.'); + + return EncryptionUtilities.contentToEncryptionString({ + version: this.version, + strategyName: this.name, + keyId: options.keyId, + encryptedValue + }); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + decrypt(value: EncryptionString, options: AesGcmDecryptOptions): string { + const content: EncryptionContent = EncryptionUtilities.encryptionStringToContent(value); + const [ivB64, tagB64, ...contentB64Parts] = content.encryptedValue.split('.'); + + const contentB64: string = contentB64Parts.join('.'); + if (!ivB64 || !tagB64 || !contentB64) { + throw new Error('Invalid encrypted value'); + } + + const decipher: DecipherGCM = createDecipheriv( + this.name, + options.key, + Buffer.from(ivB64, 'base64') + ); + + if (options.aad !== undefined) { + decipher.setAAD(Buffer.from(options.aad, 'utf8')); + } + + decipher.setAuthTag(Buffer.from(tagB64, 'base64')); + + const decrypted: Buffer = Buffer.concat([ + decipher.update(Buffer.from(contentB64, 'base64')), + decipher.final() + ]); + + return decrypted.toString('utf8'); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + generateRandomKey(): Buffer { + return randomBytes(32); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + serializeKey(key: Buffer): Buffer { + return key; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + deserializeKey(raw: Buffer): Buffer { + return raw; + } +} \ No newline at end of file diff --git a/src/auth/encryption/strategies/encryption-strategy-entity.model.ts b/src/auth/encryption/strategies/encryption-strategy-entity.model.ts new file mode 100644 index 0000000..7492d14 --- /dev/null +++ b/src/auth/encryption/strategies/encryption-strategy-entity.model.ts @@ -0,0 +1,52 @@ +import { BaseEntity } from '../../../entity/base-entity.model'; +import { Entity } from '../../../entity/decorators/entity.decorator'; +import { Property } from '../../../entity/decorators/property.decorator'; +import { OmitStrict } from '../../../types/omit-strict.type'; +import { EncryptionKey, EncryptionKeyCreateData } from '../encryption-key.model'; + +/** + * The status that an encryption strategy can have. + */ +export enum EncryptionStrategyStatus { + ACTIVE = 'ACTIVE', + DEFAULT = 'DEFAULT', + DEPRECATED = 'DEPRECATED' +} + +/** + * The encryption strategy entity that is stored in the db. + */ +@Entity({ allowOrphan: true }) +export class EncryptionStrategyEntity extends BaseEntity { + /** + * The name of the strategy. + */ + @Property.string() + name!: string; + /** + * The version of the strategy. + */ + @Property.string() + version!: string; + /** + * The status of the strategy. + */ + @Property.string({ enum: EncryptionStrategyStatus }) + status!: EncryptionStrategyStatus; + /** + * The keys for this strategy. + */ + @Property.oneToMany({ target: () => EncryptionKey, inverseSide: 'strategy' }) + keys!: EncryptionKey[]; +} + +/** + * The data for creating a new encryption strategy. + */ +export type EncryptionStrategyEntityCreateData = OmitStrict + & { + /** + * The keys for this strategy. + */ + keys: EncryptionKeyCreateData[] + }; \ No newline at end of file diff --git a/src/auth/encryption/strategies/encryption-strategy.interface.ts b/src/auth/encryption/strategies/encryption-strategy.interface.ts new file mode 100644 index 0000000..c027df4 --- /dev/null +++ b/src/auth/encryption/strategies/encryption-strategy.interface.ts @@ -0,0 +1,67 @@ +import { EncryptionString } from '../encryption.utilities'; + +/** + * The base options required for encrypting a value. + */ +export type BaseEncryptOptions = { + /** + * The id of the key to use. + */ + keyId: string, + /** + * The actual key value. + */ + key: TKey +}; + +/** + * The base options required for decrypting a value. + */ +export type BaseDecryptOptions = { + /** + * The id of the key to use. + */ + keyId: string, + /** + * The actual key value. + */ + key: TKey +}; + +/** + * Interface for an encryption strategy. + */ +export interface EncryptionStrategyInterface< + TKey, + TEncryptOptions extends BaseEncryptOptions = BaseEncryptOptions, + TDecryptOptions extends BaseDecryptOptions = BaseDecryptOptions +> { + /** + * The name of the strategy. + */ + readonly name: string, + /** + * The version of the strategy. + */ + readonly version: string, + /** + * Encrypts the given value with the given options. + */ + encrypt: (value: string, options: TEncryptOptions) => EncryptionString | Promise, + /** + * Decrypts the given value with the given options. + */ + decrypt: (value: EncryptionString, options: TDecryptOptions) => string | Promise, + /** + * Generates a new random key. + */ + generateRandomKey: () => TKey | Promise, + /** + * Serializes the given key into a Buffer. + */ + serializeKey: (key: TKey) => Buffer | Promise, + /** + * Resolves a key from the given Buffer. + */ + deserializeKey: (raw: Buffer) => TKey | Promise +} \ No newline at end of file diff --git a/src/auth/hash.utilities.ts b/src/auth/hash.utilities.ts deleted file mode 100644 index 966659b..0000000 --- a/src/auth/hash.utilities.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { hash, compare, genSalt } from 'bcryptjs'; - -/** - * Provides utilities around hashing and comparing hashes. - */ -export abstract class HashUtilities { - /** - * Hashes the given value. - * @param value - The value to hash. - * @returns A bcrypt hash. - */ - static async hash(value: string): Promise { - return await hash(value, await genSalt()); - } - /** - * Checks if the hash of the given value equals the hash. - * @param value - The value to compare. - * @param hash - The hash to compare against. - * @returns True when they are equal, false otherwise. - */ - static async equal(value: string, hash: string): Promise { - return await compare(value, hash); - } -} \ No newline at end of file diff --git a/src/auth/hash/hash-service.interface.ts b/src/auth/hash/hash-service.interface.ts new file mode 100644 index 0000000..b9c5454 --- /dev/null +++ b/src/auth/hash/hash-service.interface.ts @@ -0,0 +1,39 @@ +import { HashString } from './hash.utilities'; +import { HashStrategyInterface } from './strategies/hash-strategy.interface'; +import { AnyObject } from '../../entity/any-object.model'; +import { Newable } from '../../types/newable.type'; + +/** + * Options for hashing a value. + */ +export type HashOptions = { + /** + * The strategy to use. + */ + strategy: Newable>, + /** + * Options for the strategy. + */ + strategyOptions?: THashOptions +}; + +/** + * Interface for a hash service. + */ +export interface HashServiceInterface { + /** + * Hashes the given value with the given options. + */ + hash: ( + value: string, + options?: HashOptions + ) => HashString | Promise, + /** + * Checks whether or not the given value equals the given hash. + */ + equal: (value: string, hash: HashString) => boolean | Promise, + /** + * Checks if the given hash should be rehashed. + */ + needsRehash: (hash: HashString) => boolean | Promise +} \ No newline at end of file diff --git a/src/auth/hash/hash.service.test.ts b/src/auth/hash/hash.service.test.ts new file mode 100644 index 0000000..2569fde --- /dev/null +++ b/src/auth/hash/hash.service.test.ts @@ -0,0 +1,60 @@ +import { afterAll, beforeAll, describe, expect, test } from '@jest/globals'; + +import { HashService } from './hash.service'; +import { HashString } from './hash.utilities'; +import { HashStrategyEntity, HashStrategyStatus } from './strategies/hash-strategy-entity.model'; +import { StartedTestServer, startTestServer } from '../../__testing__/test-server/start-test-server.function'; +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'; + +describe('HashService', () => { + let server: StartedTestServer; + let hashService: HashService; + let strategyRepository: Repository; + + beforeAll(async () => { + server = await startTestServer(); + strategyRepository = inject(repositoryTokenFor(HashStrategyEntity)); + hashService = inject(ZIBRI_DI_TOKENS.HASH_SERVICE) as HashService; + }, 15000); + + afterAll(async () => { + await server?.shutdown(); + }); + + test('creates strategy metadata on first hash', async () => { + const strategiesBeforeHash: HashStrategyEntity[] = await strategyRepository.findAll(); + expect(strategiesBeforeHash).toHaveLength(0); + + const hashed: HashString = await hashService.hash('hello'); + expect(hashed).toBeTruthy(); + + const strategies: HashStrategyEntity[] = await strategyRepository.findAll(); + expect(strategies).toHaveLength(1); + expect(strategies[0]).toMatchObject({ + status: HashStrategyStatus.DEFAULT + }); + }); + + test('does not duplicate strategy metadata on subsequent hashes', async () => { + await hashService.hash('first'); + await hashService.hash('second'); + + const strategies: HashStrategyEntity[] = await strategyRepository.findAll(); + expect(strategies).toHaveLength(1); + }); + + test('verifies a hash correctly', async () => { + const hashed: HashString = await hashService.hash('secret'); + + await expect(hashService.equal('secret', hashed)).resolves.toBe(true); + await expect(hashService.equal('wrong', hashed)).resolves.toBe(false); + }); + + test('detects whether a hash needs rehashing', async () => { + const hashed: HashString = await hashService.hash('secret'); + expect(await hashService.needsRehash(hashed)).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/auth/hash/hash.service.ts b/src/auth/hash/hash.service.ts new file mode 100644 index 0000000..32d149d --- /dev/null +++ b/src/auth/hash/hash.service.ts @@ -0,0 +1,164 @@ +import { HashOptions, HashServiceInterface } from './hash-service.interface'; +import { HashContent, HashString, HashUtilities } from './hash.utilities'; +import { HashStrategyEntity, HashStrategyEntityCreateData, HashStrategyStatus } from './strategies/hash-strategy-entity.model'; +import { HashStrategyInterface } from './strategies/hash-strategy.interface'; +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 { inject } from '../../di/inject.function'; +import { register } from '../../di/register.function'; +import { OnAppInit } from '../../global/on-app-init.interface'; +import type { Newable } from '../../types/newable.type'; + +/** + * Default hash service implementation of Zibri. + */ +@Injectable({ register: 'onUse' }) +export class HashService implements HashServiceInterface, OnAppInit { + private readonly strategies: HashStrategyInterface>[] = []; + + private hasCreatedInitialDefaultStrategy: boolean = false; + + constructor( + @InjectRepository(HashStrategyEntity) + private readonly hashStrategyRepository: Repository, + @Inject(ZIBRI_DI_TOKENS.HASH_STRATEGIES) + private readonly hashStrategies: Newable>>[] + ) {} + + // eslint-disable-next-line jsdoc/require-jsdoc + async onAppInit(): Promise { + if (!this.hashStrategies.length) { + throw new Error('Needs to provide at least one hash strategy.'); + } + for (const strategy of this.hashStrategies) { + register({ token: strategy, useClass: strategy }); + this.strategies.push(inject(strategy)); + } + this.validateNoDuplicateHashStrategy(); + await this.validateNoMissingHashStrategy(); + this.validateStrategyNamesAndVersions(); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async hash>( + value: string, + options?: HashOptions + ): Promise { + if (!options) { + const strategy: HashStrategyInterface> = await this.findDefaultStrategy(); + if (!this.hasCreatedInitialDefaultStrategy && !(await this.hashStrategyRepository.findAll()).length) { + await this.hashStrategyRepository.create({ + name: strategy.name, + version: strategy.version, + status: HashStrategyStatus.DEFAULT + }); + this.hasCreatedInitialDefaultStrategy = true; + } + else if (!(await this.hashStrategyRepository.findAll({ where: { name: strategy.name, version: strategy.version } })).length) { + await this.hashStrategyRepository.create({ + name: strategy.name, + version: strategy.version, + status: HashStrategyStatus.ACTIVE + }); + } + return await strategy.hash(value, options); + } + + const strategy: HashStrategyInterface> | undefined = this.strategies.find( + s => s instanceof options.strategy + ); + if (!strategy) { + throw new Error(`The given strategy ${options.strategy.name} was not provided as part of ZIBRI_DI_TOKENS.HASH_STRATEGIES`); + } + return await strategy.hash(value, options.strategyOptions); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async equal(value: string, hash: HashString): Promise { + const strategy: HashStrategyInterface> = this.findStrategyForHash(hash); + return await strategy.equal(value, hash); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async needsRehash(hash: HashString): Promise { + const strategy: HashStrategyInterface> = this.findStrategyForHash(hash); + return (await this.hashStrategyRepository.findOne( + { where: { name: strategy.name, version: strategy.version } } + )).status === HashStrategyStatus.DEPRECATED; + } + + private findStrategy(name: string, version: string): HashStrategyInterface> { + const res: HashStrategyInterface> | undefined = this.strategies.find( + s => s.name === name && s.version === version + ); + if (!res) { + throw new Error(`No strategy found for ${name} (${version})`); + } + return res; + } + + private async findDefaultStrategy(): Promise>> { + const entity: HashStrategyEntity | undefined = await this.hashStrategyRepository.findOne( + { where: { status: HashStrategyStatus.DEFAULT } }, + false + ); + return entity + ? this.findStrategy(entity.name, entity.version) + : this.strategies[0]; + } + + private findStrategyForHash(hash: HashString): HashStrategyInterface> { + const content: HashContent = HashUtilities.hashToContent(hash); + return this.findStrategy(content.strategyName, content.version); + } + + private validateNoDuplicateHashStrategy(): void { + // validate that there is no strategy with the same name and version + const duplicateStrategies: HashStrategyInterface>[] = this.strategies.filter( + s => this.strategies.filter(hs => hs.name === s.name && hs.version === s.version).length > 1 + ); + if (duplicateStrategies.length) { + throw new Error( + [ + 'There are duplicate hash strategies:', + [...new Set(duplicateStrategies)].map(s => `- ${s.name} (${s.version})`) + ].join('\n') + ); + } + } + + private async validateNoMissingHashStrategy(): Promise { + const strategies: HashStrategyEntity[] = await this.hashStrategyRepository.findAll(); + const missingStrategies: HashStrategyEntity[] = strategies.filter( + s => !this.strategies.some(hs => hs.name === s.name && hs.version === s.version) + ); + if (missingStrategies.length) { + const message: string[] = ['Error initializing hash service.', 'There are missing strategies:']; + for (const strategy of missingStrategies) { + message.push(` - ${strategy.name} (${strategy.version})`); + } + message.push('Did you remove them?'); + throw new Error(message.join('\n')); + } + } + + private validateStrategyNamesAndVersions(): void { + const strategiesWithDotsInNameOrVersion: HashStrategyInterface>[] = this.strategies.filter( + s => s.name.includes('.') || s.version.includes('.') + ); + if (!strategiesWithDotsInNameOrVersion.length) { + return; + } + const message: string[] = [ + 'Error initializing hash service.', + 'There are strategies that use dots in either the version or the name:' + ]; + for (const strategy of strategiesWithDotsInNameOrVersion) { + message.push(` - ${strategy.name} (${strategy.version})`); + } + throw new Error(message.join('\n')); + } +} \ No newline at end of file diff --git a/src/auth/hash/hash.utilities.ts b/src/auth/hash/hash.utilities.ts new file mode 100644 index 0000000..53e221c --- /dev/null +++ b/src/auth/hash/hash.utilities.ts @@ -0,0 +1,60 @@ + +/** + * A string with hashed data stored inside of it. + */ +export type HashString = string & { + // eslint-disable-next-line jsdoc/require-jsdoc + __brand: 'hash' +}; + +/** + * The content stored inside a hashed string. + */ +export type HashContent = { + /** + * The name of the hash strategy. + */ + strategyName: string, + /** + * The version of the hash strategy. + */ + version: string, + /** + * The actual hashed value. + */ + hashedValue: string +}; + +/** + * Utilities around handling hashing. + */ +export abstract class HashUtilities { + /** + * Creates a hash string from the given content. + * @param data - The content to store inside the hash string. + * @returns The hash string in the form "strategyName.version.hashedValue". + */ + static contentToHash(data: HashContent): HashString { + return [data.strategyName, data.version, data.hashedValue].join('.') as HashString; + } + /** + * Resolves the content of the given hash string. + * @param hash - The hash string to resolve the content of. + * @returns The hash content inside the given string. + * @throws If the hash string is invalid. + */ + static hashToContent(hash: HashString): HashContent { + const [strategyName, version, ...hashedValueParts] = hash.split('.'); + const hashedValue: string = hashedValueParts.join('.'); + + if (!strategyName || !version || !hashedValue) { + throw new Error('Invalid hash string'); + } + + return { + strategyName, + version, + hashedValue + }; + } +} \ No newline at end of file diff --git a/src/auth/hash/strategies/bcrypt.hash-strategy.ts b/src/auth/hash/strategies/bcrypt.hash-strategy.ts new file mode 100644 index 0000000..bf30805 --- /dev/null +++ b/src/auth/hash/strategies/bcrypt.hash-strategy.ts @@ -0,0 +1,39 @@ +import { compare, hash } from 'bcryptjs'; + +import { HashStrategyInterface } from './hash-strategy.interface'; +import { HashContent, HashString, HashUtilities } from '../hash.utilities'; + +/** + * Options for hashing via the bcrypt hash strategy. + */ +export type BcryptHashOptions = { + /** + * How many rounds should be used for the salt. + */ + rounds?: number +}; + +/** + * Default bcrypt hash strategy implementation of zibri. + */ +export class BcryptHashStrategy implements HashStrategyInterface { + // eslint-disable-next-line jsdoc/require-jsdoc + readonly name: string = 'bcrypt'; + // eslint-disable-next-line jsdoc/require-jsdoc + readonly version: string = 'v1'; + + // eslint-disable-next-line jsdoc/require-jsdoc + async hash( + value: string, + options: BcryptHashOptions | undefined + ): Promise { + const hashedValue: string = await hash(value, options?.rounds ?? 10); + return HashUtilities.contentToHash({ strategyName: this.name, version: this.version, hashedValue }); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async equal(value: string, hash: HashString): Promise { + const content: HashContent = HashUtilities.hashToContent(hash); + return await compare(value, content.hashedValue); + } +} \ No newline at end of file diff --git a/src/auth/hash/strategies/hash-strategy-entity.model.ts b/src/auth/hash/strategies/hash-strategy-entity.model.ts new file mode 100644 index 0000000..df4d5f2 --- /dev/null +++ b/src/auth/hash/strategies/hash-strategy-entity.model.ts @@ -0,0 +1,40 @@ +import { BaseEntity } from '../../../entity/base-entity.model'; +import { Entity } from '../../../entity/decorators/entity.decorator'; +import { Property } from '../../../entity/decorators/property.decorator'; +import { OmitStrict } from '../../../types/omit-strict.type'; + +/** + * The status that a hash strategy can have. + */ +export enum HashStrategyStatus { + ACTIVE = 'ACTIVE', + DEFAULT = 'DEFAULT', + DEPRECATED = 'DEPRECATED' +} + +/** + * The hash strategy entity that is stored in the db. + */ +@Entity({ allowOrphan: true }) +export class HashStrategyEntity extends BaseEntity { + /** + * The name of the strategy. + */ + @Property.string() + name!: string; + /** + * The version of the strategy. + */ + @Property.string() + version!: string; + /** + * The status of the strategy. + */ + @Property.string({ enum: HashStrategyStatus }) + status!: HashStrategyStatus; +} + +/** + * The data for creating a new hash strategy entity. + */ +export type HashStrategyEntityCreateData = OmitStrict; \ No newline at end of file diff --git a/src/auth/hash/strategies/hash-strategy.interface.ts b/src/auth/hash/strategies/hash-strategy.interface.ts new file mode 100644 index 0000000..627e57a --- /dev/null +++ b/src/auth/hash/strategies/hash-strategy.interface.ts @@ -0,0 +1,23 @@ +import { HashString } from '../hash.utilities'; + +/** + * Interface for a hash strategy. + */ +export interface HashStrategyInterface> { + /** + * The name of the strategy. + */ + name: string, + /** + * The version of the strategy. + */ + version: string, + /** + * Hashes the given value with the given options. + */ + hash: (value: string, options: THashOptions | undefined) => HashString | Promise, + /** + * Checks whether or not the given value equals the given hash. + */ + equal: (value: string, hash: HashString) => boolean | Promise +} \ No newline at end of file diff --git a/src/auth/strategies/auth-strategy.interface.ts b/src/auth/strategies/auth-strategy.interface.ts index 92c881b..f5ca9e0 100644 --- a/src/auth/strategies/auth-strategy.interface.ts +++ b/src/auth/strategies/auth-strategy.interface.ts @@ -1,9 +1,10 @@ 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'; -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'; /** @@ -19,10 +20,14 @@ export interface AuthStrategyInterface< RefreshLoginDataType, LogoutData > { + /** + * Initializes the auth strategy. + */ + init?: (app: ZibriApplication) => void | Promise, /** * Resolves the current user. */ - resolveUser: (request: HttpRequest | WebsocketRequest) => Promise, + resolveUser: (context: HttpRequestContext | WebsocketRequestContext) => Promise, /** * Logs in a user. */ @@ -38,16 +43,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/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..1bb3c47 --- /dev/null +++ b/src/auth/strategies/cookie/cookie-auth-credentials.model.ts @@ -0,0 +1,56 @@ +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 type { HashString } from '../../hash/hash.utilities'; +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 hashed password. + */ + @Property.string({ hash: true }) + password!: HashString; +} + +/** + * The data that is used to login a user via the cookie auth auth strategy. + */ +export class CookieAuthCredentialsData extends OmitClass(CookieAuthCredentials, ['id', 'userId', 'password']) { + /** + * The password. + */ + @Property.string() + password!: string; + /** + * 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..8bbfb48 --- /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 { type HashServiceInterface } from '../../hash/hash-service.interface'; +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.HASH_SERVICE) + private readonly hashService: HashServiceInterface, + @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 this.hashService.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') + ); + const session: CookieAuthSession = await this.createSession(refreshSession, credentials.transaction); + this.setRefreshSessionCookie(refreshSession); + 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); + + await this.credentialsRepository.updateById(credentials.id, { password: data.newPassword }, { 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-credentials.model.ts b/src/auth/strategies/jwt/jwt-credentials.model.ts index 9a5a3d0..186f7c9 100644 --- a/src/auth/strategies/jwt/jwt-credentials.model.ts +++ b/src/auth/strategies/jwt/jwt-credentials.model.ts @@ -2,6 +2,7 @@ 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 type { HashString } from '../../hash/hash.utilities'; import { BaseUser } from '../../models/base-user.model'; /** @@ -22,16 +23,22 @@ export class JwtCredentials extends BaseEntity implements Pick, email!: string; /** - * The password. + * The hashed password. */ - @Property.string() - password!: string; + @Property.string({ hash: true }) + password!: HashString; } /** * The actual credentials sent over http. */ -export class JwtCredentialsDto extends OmitClass(JwtCredentials, ['id', 'userId']) {} +export class JwtCredentialsDto extends OmitClass(JwtCredentials, ['id', 'userId', 'password']) { + /** + * The password. + */ + @Property.string() + password!: string; +} /** * The data for creating new jwt credentials. 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 79e24df..eda76a7 100644 --- a/src/auth/strategies/jwt/jwt.auth-strategy.ts +++ b/src/auth/strategies/jwt/jwt.auth-strategy.ts @@ -4,13 +4,18 @@ import { EncodedJwtAccessToken } from './encoded-jwt-access-token.model'; import { JwtAccessTokenPayload } from './jwt-access-token-payload.model'; import { JwtAuthData } from './jwt-auth-data.model'; import { JwtConfirmPasswordResetData } from './jwt-confirm-password-reset-data.model'; -import { JwtCredentials, JwtCredentialsDto } from './jwt-credentials.model'; +import { JwtCredentials, JwtCredentialsCreateData, 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'; @@ -22,19 +27,18 @@ 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 { OmitStrict } from '../../../types/omit-strict.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'; 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'; +import { type HashServiceInterface } from '../../hash/hash-service.interface'; /** * 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'; @@ -75,17 +79,19 @@ implements AuthStrategyInterface< @InjectRepository(PasswordResetToken) private readonly passwordResetTokenRepository: Repository, @InjectRepository(JwtCredentials) - private readonly credentialsRepository: Repository, + private readonly credentialsRepository: Repository, @Inject(ZIBRI_DI_TOKENS.JWT_ACCESS_TOKEN_EXPIRES_IN_MS) 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, @Inject(ZIBRI_DI_TOKENS.EMAIL_SERVICE) - private readonly emailService: EmailServiceInterface + private readonly emailService: EmailServiceInterface, + @Inject(ZIBRI_DI_TOKENS.HASH_SERVICE) + private readonly hashService: HashServiceInterface ) { const accessTokenSecret: string | undefined = inject(ZIBRI_DI_TOKENS.JWT_ACCESS_TOKEN_SECRET); if (!accessTokenSecret) { @@ -95,15 +101,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,18 +118,25 @@ 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 { const foundUser: UserType = await this.userService.findByEmail(credentials.email); const credentialsFound: JwtCredentials = await this.userService.resolveCredentialsFor(foundUser); - const passwordMatched: boolean = await HashUtilities.equal(credentials.password, credentialsFound.password); + const passwordMatched: boolean = await this.hashService.equal(credentials.password, credentialsFound.password); if (!passwordMatched) { throw new UnauthorizedError('Invalid email or password.'); } 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 +157,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 +173,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 +209,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 +223,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 +250,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,50 +268,50 @@ 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'); } const user: UserType = await this.userService.findById(resetToken.userId); const credentials: JwtCredentials = await this.userService.resolveCredentialsFor(user); - 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, { password: data.newPassword }, { 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; } // 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 +324,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 +334,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 +348,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 +363,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 +379,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/backup/backup.service.ts b/src/backup/backup.service.ts index bf27212..817e39e 100644 --- a/src/backup/backup.service.ts +++ b/src/backup/backup.service.ts @@ -24,6 +24,7 @@ import { BackupTransportInterface } from './transports/backup-transport.interfac import { Repository } from '../data-source/repository'; import { OnAppInit } from '../global/on-app-init.interface'; import { OnAppShutdown } from '../global/on-app-shutdown.interface'; +import { JsonUtilities } from '../utilities/json.utilities'; import { Ms } from '../utilities/ms'; /** @@ -266,7 +267,7 @@ export class BackupService implements BackupServiceInterface, OnAppInit, OnAppSh [ `Could not resolve backup data for resource "${resource.name}" from transport: ${transport.name}.`, 'Cause:', - error instanceof Error ? error.message : JSON.stringify(error) + error instanceof Error ? error.message : JsonUtilities.stringify(error) ].join('\n') ); } diff --git a/src/backup/transports/fs.backup-transport.ts b/src/backup/transports/fs.backup-transport.ts index 9cc5e5f..9412922 100644 --- a/src/backup/transports/fs.backup-transport.ts +++ b/src/backup/transports/fs.backup-transport.ts @@ -7,6 +7,7 @@ import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; import { inject } from '../../di/inject.function'; import { LoggerInterface } from '../../logging/logger.interface'; import { FsUtilities, FsPath } from '../../utilities/fs.utilities'; +import { JsonUtilities } from '../../utilities/json.utilities'; import { BackupEntity } from '../backup-entity.model'; import { BackupResourceEntity } from '../backup-resource-entity.model'; @@ -43,7 +44,7 @@ export class FsBackupTransport implements BackupTransportInterface { return; } const json: string = await FsUtilities.readFile(p); - return JSON.parse(json) as BackupEntity; + return JsonUtilities.parse(json); }))).filter(b => b != undefined); return res; } @@ -52,7 +53,7 @@ export class FsBackupTransport implements BackupTransportInterface { async storeData(data: Readable, backup: BackupEntity, resource: BackupResourceEntity): Promise { const p: FsPath = this.getResourcePath(backup, resource); await FsUtilities.mkdir(this.getBackupPath(backup)); - await FsUtilities.createFile(this.getBackupMetadataPath(backup), JSON.stringify(backup)); + await FsUtilities.createFile(this.getBackupMetadataPath(backup), JsonUtilities.stringify(backup)); await pipeline( data, diff --git a/src/caching/cache-metrics.model.ts b/src/caching/cache-metrics.model.ts new file mode 100644 index 0000000..b2131f3 --- /dev/null +++ b/src/caching/cache-metrics.model.ts @@ -0,0 +1,57 @@ +import { CounterInterface } from '../metrics/counter.interface'; +import { GaugeInterface } from '../metrics/gauge.interface'; +import { HistogramInterface } from '../metrics/histogram.interface'; + +/** + * Metrics regarding caching. + */ +export type CacheMetrics = { + /** + * How many times caches have been hit. + */ + hits: CounterInterface, + /** + * How many times caches have been missed. + */ + misses: CounterInterface, + /** + * How many times values have been written to caches. + */ + writes: CounterInterface, + /** + * How many times values have been deleted from caches. + */ + deletes: CounterInterface, + /** + * How many times cache invalidations have happened. + */ + invalidations: CounterInterface, + /** + * How many times cache invalidations have failed. + */ + invalidationFailures: CounterInterface, + /** + * How many times cached values have expired. + */ + expiredEvictions: CounterInterface, + /** + * How many times errors occurred during caching. + */ + errors: CounterInterface, + /** + * The current amount of inFlight cache operations. + */ + inFlight: GaugeInterface, + /** + * The amount of cached values. + */ + size: GaugeInterface, + /** + * The duration of the original functions. + */ + sourceDuration: HistogramInterface, + /** + * The duration of cache operations. + */ + storeDuration: HistogramInterface +}; \ No newline at end of file diff --git a/src/caching/cache-service.interface.ts b/src/caching/cache-service.interface.ts new file mode 100644 index 0000000..1ab6220 --- /dev/null +++ b/src/caching/cache-service.interface.ts @@ -0,0 +1,15 @@ +import { AnyCache } from './cache/cache.interface'; + +/** + * Interface for a cache service. + */ +export interface CacheServiceInterface { + /** + * All caches that have been registered. + */ + readonly caches: AnyCache[], + /** + * Invalidates the given tags through all caches. + */ + invalidateTags: (tags: string[]) => void | Promise +} \ No newline at end of file diff --git a/src/caching/cache-tag-matchers.test.ts b/src/caching/cache-tag-matchers.test.ts new file mode 100644 index 0000000..02f54a2 --- /dev/null +++ b/src/caching/cache-tag-matchers.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, jest } from '@jest/globals'; + +import { CacheTagMatcher, matchesAnyTag, matchesTag } from './cache-tag-matchers'; + +describe('matchesTag', () => { + it('matches exact string tags', () => { + expect(matchesTag('user:1', 'user:1')).toBe(true); + expect(matchesTag('user:1', 'user:2')).toBe(false); + }); + + it('matches regex tags', () => { + expect(matchesTag(/^user:\d+$/, 'user:1')).toBe(true); + expect(matchesTag(/^user:\d+$/, 'profile:1')).toBe(false); + }); + + it('matches predicate tags', () => { + const matcher: CacheTagMatcher = (tag: string): boolean => tag.startsWith('user:'); + expect(matchesTag(matcher, 'user:1')).toBe(true); + expect(matchesTag(matcher, 'profile:1')).toBe(false); + }); + + it('supports broad regex matches', () => { + expect(matchesTag(/.*/, 'anything')).toBe(true); + }); + + it('regex matching is case sensitive by default', () => { + expect(matchesTag(/^user$/, 'User')).toBe(false); + }); + + it('predicate matchers receive the exact tag string', () => { + const matcher: CacheTagMatcher = jest.fn((tag: string): boolean => tag === 'abc'); + expect(matchesTag(matcher, 'abc')).toBe(true); + expect(matcher).toHaveBeenCalledTimes(1); + expect(matcher).toHaveBeenCalledWith('abc'); + }); +}); + +describe('matchesAnyTag', () => { + it('returns true when at least one matcher matches at least one tag', () => { + const matchers: CacheTagMatcher[] = ['foo', /^bar$/, (tag: string): boolean => tag === 'baz'] as const; + const tags: string[] = ['nope', 'baz']; + + expect(matchesAnyTag(matchers, tags)).toBe(true); + }); + + it('returns false when no matcher matches any tag', () => { + const matchers: CacheTagMatcher[] = ['foo', /^bar$/, (tag: string): boolean => tag === 'baz'] as const; + const tags: string[] = ['nope', 'also-nope']; + + expect(matchesAnyTag(matchers, tags)).toBe(false); + }); + + it('returns true for a single string match', () => { + expect(matchesAnyTag(['user:1'], ['x', 'user:1', 'y'])).toBe(true); + }); + + it('returns true for a single regex match', () => { + expect(matchesAnyTag([/^user:\d+$/], ['profile:1', 'user:42'])).toBe(true); + }); + + it('returns true for a single predicate match', () => { + expect(matchesAnyTag([(tag: string): boolean => tag.endsWith(':write')], ['cache:read', 'cache:write'])).toBe(true); + }); + + it('returns false for empty matcher list', () => { + expect(matchesAnyTag([], ['a', 'b'])).toBe(false); + }); + + it('returns false for empty tag list', () => { + expect(matchesAnyTag(['a', /b/, (tag: string): boolean => tag === 'c'], [])).toBe(false); + }); + + it('short-circuits once a match is found', () => { + const matcher1: CacheTagMatcher = jest.fn((tag: string): boolean => tag === 'match'); + const matcher2: CacheTagMatcher = jest.fn((): boolean => true); + + expect(matchesAnyTag([matcher1, matcher2], ['match'])).toBe(true); + expect(matcher1).toHaveBeenCalledTimes(1); + expect(matcher2).not.toHaveBeenCalled(); + }); + + it('checks tags against all matchers until one succeeds', () => { + const matcher1: CacheTagMatcher = jest.fn((tag: string): boolean => tag === 'nope'); + const matcher2: CacheTagMatcher = jest.fn((tag: string): boolean => tag === 'match'); + + expect(matchesAnyTag([matcher1, matcher2], ['a', 'match'])).toBe(true); + expect(matcher1).toHaveBeenCalled(); + expect(matcher2).toHaveBeenCalled(); + }); + + it('handles duplicate tags without issue', () => { + expect(matchesAnyTag(['x'], ['a', 'x', 'x'])).toBe(true); + }); + + it('handles duplicate matchers without issue', () => { + const matcher: CacheTagMatcher = jest.fn((tag: string): boolean => tag === 'x'); + + expect(matchesAnyTag([matcher, matcher], ['x'])).toBe(true); + expect(matcher).toHaveBeenCalledTimes(1); + }); + + it('does not mutate matchers or tags', () => { + const matchers: CacheTagMatcher[] = ['x', /y/]; + const tags: string[] = ['x', 'y']; + + const matchersBefore: CacheTagMatcher[] = [...matchers]; + const tagsBefore: string[] = [...tags]; + + matchesAnyTag(matchers, tags); + + expect(matchers).toEqual(matchersBefore); + expect(tags).toEqual(tagsBefore); + }); +}); \ No newline at end of file diff --git a/src/caching/cache-tag-matchers.ts b/src/caching/cache-tag-matchers.ts new file mode 100644 index 0000000..b9923d0 --- /dev/null +++ b/src/caching/cache-tag-matchers.ts @@ -0,0 +1,31 @@ + +/** + * Matcher for a tag. + */ +export type CacheTagMatcher = | string | RegExp | ((tag: string) => boolean); + +/** + * Checks whether the given matchers match any of the given tags. + * @param matchers - The matchers used for the check. + * @param tags - The tags to check. + * @returns True if any of the tags are matched by at least one of the matchers, false otherwise. + */ +export function matchesAnyTag(matchers: readonly CacheTagMatcher[], tags: string[]): boolean { + return matchers.some(matcher => tags.some(tag => matchesTag(matcher, tag))); +} + +/** + * Checks whether or not the given matcher matches the given tag. + * @param matcher - The matcher used for the check. + * @param tag - The tag to check. + * @returns True if the matcher matches the tag, false otherwise. + */ +export function matchesTag(matcher: CacheTagMatcher, tag: string): boolean { + if (typeof matcher === 'string') { + return matcher === tag; + } + if (matcher instanceof RegExp) { + return matcher.test(tag); + } + return matcher(tag); +} \ No newline at end of file diff --git a/src/caching/cache.service.test.ts b/src/caching/cache.service.test.ts new file mode 100644 index 0000000..f48373d --- /dev/null +++ b/src/caching/cache.service.test.ts @@ -0,0 +1,211 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; + +import { type CacheServiceInterface } from './cache-service.interface'; +import { CacheService } from './cache.service'; +import { Cache } from './decorators/cache.decorator'; +import { CachedValue } from './store/cached-value.model'; +import { InMemoryCacheStore } from './store/in-memory.cache-store'; +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 { WriteThroughReadThroughCache } from './cache/read-through/write-through-read-through.cache'; + +@Cache() +class UserTagCache 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('User Tag Cache', new InMemoryCacheStore(), ['user']); + } +} + +@Cache() +class PostTagCache 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('Post Tag Cache', new InMemoryCacheStore(), ['post']); + } +} + +@Cache() +class UserRegexCache 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('User Regex Cache', new InMemoryCacheStore(), [/^user:/]); + } +} + +@Cache() +class PostRegexCache 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('Post Regex Cache', new InMemoryCacheStore(), [/^post:/]); + } +} + +@Cache() +class UserPredicateCache 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('User Predicate Cache', new InMemoryCacheStore(), [(tag: string) => tag.startsWith('user:')]); + } +} + +@Cache() +class OtherPredicateCache 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('Other Predicate Cache', new InMemoryCacheStore(), [(tag: string) => tag.startsWith('other:')]); + } +} + +@Cache() +class AllTagsCache 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('All Tags Cache', new InMemoryCacheStore(), 'all'); + } +} + +@Cache() +class UnrelatedTagCache 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('Unrelated Tag Cache', new InMemoryCacheStore(), ['unrelated']); + } +} + +function createCachedValue(value: V, tags: string[]): CachedValue { + return { value, tags, createdAt: new Date() }; +} + +describe('CacheService', () => { + let server: StartedTestServer; + let cacheService: CacheService; + + beforeAll(async () => { + server = await startTestServer(); + cacheService = inject(ZIBRI_DI_TOKENS.CACHE_SERVICE) as CacheService; + }, 15000); + + afterAll(async () => { + await server?.shutdown(); + }); + + beforeEach(async () => { + cacheService.caches.length = 0; + await inject(UserTagCache).store.clear(); + await inject(PostTagCache).store.clear(); + await inject(UserRegexCache).store.clear(); + await inject(PostRegexCache).store.clear(); + await inject(UserPredicateCache).store.clear(); + await inject(OtherPredicateCache).store.clear(); + await inject(AllTagsCache).store.clear(); + await inject(UnrelatedTagCache).store.clear(); + }); + + it('invalidates only caches whose declared tags match', async () => { + const userCache: UserTagCache = inject(UserTagCache); + const postCache: PostTagCache = inject(PostTagCache); + + cacheService.caches.push(userCache, postCache); + + await userCache.store.set('u1', createCachedValue('user-value', ['user'])); + await postCache.store.set('p1', createCachedValue('post-value', ['post'])); + + await cacheService.invalidateTags(['user']); + + expect(await userCache.store.get('u1')).toBeUndefined(); + expect((await postCache.store.get('p1'))?.value).toBe('post-value'); + }); + + it('invalidates caches with tag matchers based on regex', async () => { + const userCache: UserRegexCache = inject(UserRegexCache); + const postCache: PostRegexCache = inject(PostRegexCache); + + cacheService.caches.push(userCache, postCache); + + await userCache.store.set('u1', createCachedValue('user-value', ['user:1'])); + await postCache.store.set('p1', createCachedValue('post-value', ['post:1'])); + + await cacheService.invalidateTags(['user:1']); + + expect(await userCache.store.get('u1')).toBeUndefined(); + expect((await postCache.store.get('p1'))?.value).toBe('post-value'); + }); + + it('invalidates caches with tag matchers based on predicate functions', async () => { + const userCache: UserPredicateCache = inject(UserPredicateCache); + const otherCache: OtherPredicateCache = inject(OtherPredicateCache); + + cacheService.caches.push(userCache, otherCache); + + await userCache.store.set('u1', createCachedValue('user-value', ['user:1'])); + await otherCache.store.set('o1', createCachedValue('other-value', ['other:1'])); + + await cacheService.invalidateTags(['user:1']); + + expect(await userCache.store.get('u1')).toBeUndefined(); + expect((await otherCache.store.get('o1'))?.value).toBe('other-value'); + }); + + it('treats tag matchers as an optimization and only calls invalidateTags on affected caches', async () => { + const allCache: AllTagsCache = inject(AllTagsCache); + const postCache: PostTagCache = inject(PostTagCache); + + cacheService.caches.push(allCache, postCache); + + await allCache.store.set('a', createCachedValue('all-value', ['keep-me'])); + await postCache.store.set('p', createCachedValue('post-value', ['post'])); + + // eslint-disable-next-line typescript/typedef + const allInvalidateSpy = jest.spyOn(allCache.store, 'invalidateTags'); + // eslint-disable-next-line typescript/typedef + const postInvalidateSpy = jest.spyOn(postCache.store, 'invalidateTags'); + + await cacheService.invalidateTags(['user']); + + expect(allInvalidateSpy).toHaveBeenCalledTimes(1); + expect(postInvalidateSpy).not.toHaveBeenCalled(); + + expect((await allCache.store.get('a'))?.value).toBe('all-value'); + expect((await postCache.store.get('p'))?.value).toBe('post-value'); + }); + + it('does nothing when no cache declares matching tags', async () => { + const cache: UnrelatedTagCache = inject(UnrelatedTagCache); + + cacheService.caches.push(cache); + await cache.store.set('x', createCachedValue('value', ['x'])); + + await cacheService.invalidateTags(['different']); + + expect((await cache.store.get('x'))?.value).toBe('value'); + }); +}); \ No newline at end of file diff --git a/src/caching/cache.service.ts b/src/caching/cache.service.ts new file mode 100644 index 0000000..6085ad7 --- /dev/null +++ b/src/caching/cache.service.ts @@ -0,0 +1,69 @@ +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'; +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 { type LoggerInterface } from '../logging/logger.interface'; +import { MultiTierCache } from './cache/multi-tier.cache'; + +/** + * Default implementation of the cache service. + */ +@Injectable({ register: 'onUse' }) +export class CacheService implements CacheServiceInterface, OnAppInit { + // eslint-disable-next-line jsdoc/require-jsdoc + readonly caches: AnyCache[] = []; + + constructor( + @Inject(ZIBRI_DI_TOKENS.LOGGER) + private readonly logger: LoggerInterface + ) {} + + // eslint-disable-next-line jsdoc/require-jsdoc + async onAppInit(): Promise { + if (GlobalRegistry.cacheClasses.length) { + // eslint-disable-next-line stylistic/max-len + await this.logger.info(`configures ${GlobalRegistry.cacheClasses.length} ${GlobalRegistry.cacheClasses.length > 1 ? 'caches' : 'cache'}:`); + } + + for (const cacheClass of GlobalRegistry.cacheClasses) { + const cache: AnyCache = inject(cacheClass); + this.caches.push(cache); + if (!isCache(cache)) { + throw new Error(`Invalid class marked with @Cache: ${cacheClass.name} needs to implement CacheInterface`); + } + await this.logger.info(` - ${cache.name}`); + } + + for (const cacheInjectable of GlobalRegistry.injectables.map(i => inject(i.token)).filter(i => isCache(i))) { + if (!this.caches.find(c => c.name === cacheInjectable.name)) { + throw new Error(`The class "${cacheInjectable.constructor}" seems to be a cache but has not been decorated with @Cache()`); + } + } + + const duplicateCacheNames: AnyCache[] = this.caches.filter( + c => this.caches.filter(internalC => internalC.name === c.name).length > 1 + ); + if (duplicateCacheNames.length) { + throw new Error( + [ + 'There are duplicate cache names:', + [...new Set(duplicateCacheNames)].map(s => `- ${s.name}`) + ].join('\n') + ); + } + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async invalidateTags(tags: string[]): Promise { + // Only hit caches that actually declared these 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 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 new file mode 100644 index 0000000..bbd8c8a --- /dev/null +++ b/src/caching/cache/base-cache.model.ts @@ -0,0 +1,333 @@ +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 { OmitStrict } from '../../types/omit-strict.type'; +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, 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'; + +/** + * Shared base class of all caches. + */ +export abstract class BaseCache +implements OmitStrict, 'wrap' | 'wrapWrite'> { + + private readonly inFlight: Map> = new Map(); + + private _metrics: CacheMetrics | undefined; + + protected abstract cacheService: CacheServiceInterface; + + protected abstract logger: LoggerInterface; + + protected abstract metricsService: MetricsServiceInterface; + + abstract readonly _writeResultAvailable: WriteResultAvailable; + + // eslint-disable-next-line jsdoc/require-returns + /** + * The cache metrics. + */ + protected get metrics(): CacheMetrics { + this._metrics ??= this.initMetrics(); + return this._metrics; + } + + constructor( + readonly name: N, + readonly store: CacheStoreInterface, + readonly tags: 'all' | readonly CacheTagMatcher[], + readonly defaultTtl?: number | (() => number | Promise), + readonly onInvalidationFailure?: OnInvalidationFailure + ) {} + + // 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 sourceStart: number = performance.now(); + const res: TReturn = await fn(...args); + cacheCtx.durationInMs = performance.now() - sourceStart; + this.metrics.sourceDuration.observe({ cache: this.name, operation: CacheOperation.DELETE }, cacheCtx.durationInMs); + + await Promise.all([ + Promise.resolve() + .then(async () => { + const key: K = await keyFn(...args); + cacheCtx.key = key; + + 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(async (error) => { + this.metrics.errors.increase({ cache: this.name, operation: 'delete' }); + await this.logger.warn( + 'Cache deletion failed after successful source delete', + { error } + ); + }), + this.safeInvalidateTags(options?.invalidatesTags, args) + ]); + + 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 sourceStart: number = performance.now(); + const result: TReturn = await fn(...args); + cacheCtx.durationInMs = performance.now() - sourceStart; + this.metrics.sourceDuration.observe({ cache: this.name, operation: CacheOperation.INVALIDATE }, cacheCtx.durationInMs); + + await this.safeInvalidateTags(options.invalidatesTags, args); + + return result; + }); + }; + } + + abstract setDirect(key: K, value: V, options?: CacheSetDirectOptions): Promise; + + private initMetrics(): CacheMetrics { + const label: string[] = ['cache']; + const labelWithOp: string[] = ['cache', 'operation']; + + return { + hits: this.metricsService.getCounter('cache_hits_total', label), + misses: this.metricsService.getCounter('cache_misses_total', label), + writes: this.metricsService.getCounter('cache_writes_total', label), + deletes: this.metricsService.getCounter('cache_deletes_total', label), + invalidations: this.metricsService.getCounter('cache_invalidations_total', label), + invalidationFailures: this.metricsService.getCounter('cache_invalidation_failures_total', label), + expiredEvictions: this.metricsService.getCounter('cache_expired_evictions_total', label), + errors: this.metricsService.getCounter('cache_errors_total', labelWithOp), + inFlight: this.metricsService.getGauge('cache_in_flight', label), + size: this.metricsService.getGauge('cache_size', label), + sourceDuration: this.metricsService.getHistogram( + 'cache_source_duration_ms', + labelWithOp, + [1, 5, 10, 25, 50, 100, 250, 500, 1000] + ), + storeDuration: this.metricsService.getHistogram('cache_store_duration_ms', labelWithOp, [1, 5, 10, 25, 50, 100, 250, 500]) + }; + } + + /** + * Updates the size gauge metric. + */ + protected async updateSizeGauge(): Promise { + try { + this.metrics.size.set({ cache: this.name }, await this.store.size()); + } + catch { + // not critical — never surface gauge update failures + } + } + + /** + * Runs the given factory under the given key in single flight. + * @param key - The key to check if a operation is already ongoing. + * @param factory - The operation to run. + * @returns The resolving promise. + */ + protected async runSingleFlight(key: K, factory: () => Promise): Promise { + const existing: Promise | undefined = this.inFlight.get(key); + if (existing !== undefined) { + return existing; + } + + const promise: Promise = (async () => { + try { + return await factory(); + } + finally { + this.inFlight.delete(key); + this.metrics.inFlight.set({ cache: this.name }, this.inFlight.size); + } + })(); + + this.inFlight.set(key, promise); + this.metrics.inFlight.set({ cache: this.name }, this.inFlight.size); + return promise; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + protected async safeInvalidateTags( + provider: CacheTagsProvider | undefined, + args: TArgs + ): Promise; + // eslint-disable-next-line jsdoc/require-jsdoc + protected async safeInvalidateTags( + provider: ResultCacheTagsProvider | undefined, + result: V, + args: TArgs + ): Promise; + /** + * Safely invalidates tags. + * @param provider - The provider for the tags to invalidate. + * @param resultOrArgs - Either the result or The method arguments, depending on whether the result is available. + * @param maybeArgs - The method arguments if a result is available, undefined otherwise. + */ + protected async safeInvalidateTags( + provider: CacheTagsProvider | ResultCacheTagsProvider | undefined, + resultOrArgs: V | TArgs, + maybeArgs?: TArgs + ): Promise { + try { + const tags: CacheTag[] = maybeArgs !== undefined + ? await this.resolveResultTags(provider as ResultCacheTagsProvider, resultOrArgs as V, maybeArgs) + : await this.resolveArgTags(provider as CacheTagsProvider, resultOrArgs as TArgs); + + if (!tags.length) { + return; + } + + await this.cacheService.invalidateTags(tags); + this.metrics.invalidations.increase({ cache: this.name }); + // size may have changed as other caches invalidated entries + await this.updateSizeGauge(); + + } + catch (error) { + this.metrics.invalidationFailures.increase({ cache: this.name }); + if (this.onInvalidationFailure === 'throw') { + throw error; + } + else { + await this.logger.warn( + 'Cache invalidation failed, stale data may be served', + { error } + ); + } + } + } + + /** + * Resolves tags from a result provider. + * @param provider - The provider to resolve the tags from. + * @param result - The result. + * @param args - Additional arguments. + * @returns All resolved CacheTags. + */ + protected async resolveResultTags( + provider: ResultCacheTagsProvider | undefined, + result: V, + args: TArgs + ): Promise { + if (!provider) { + return []; + } + if (typeof provider === 'function') { + return await provider(result, ...args); + } + return provider; + } + + /** + * Resolves tags from a provider. + * @param provider - The provider to resolve the tags from. + * @param args - The arguments. + * @returns All resolved CacheTags. + */ + protected async resolveArgTags( + provider: CacheTagsProvider | undefined, + args: TArgs + ): Promise { + if (!provider) { + return []; + } + if (typeof provider === 'function') { + return await provider(...args); + } + return provider; + } + + /** + * Resolves ttl from a result provider. + * @param provider - The provider to resolve the ttl from. + * @param result - The result. + * @param args - The arguments. + * @returns The resolved time to live or undefined if values should be kept in cache without time limit. + */ + protected async resolveResultTtl( + provider: ResultCacheTtlProvider | undefined, + result: V, + args: TArgs + ): Promise { + if (provider == undefined) { + if (typeof this.defaultTtl === 'function') { + return await this.defaultTtl(); + } + return this.defaultTtl; + } + if (typeof provider === 'function') { + return await provider(result, ...args); + } + return provider; + } + + /** + * Resolves ttl from a provider. + * @param provider - The provider to resolve the ttl from. + * @param args - The arguments. + * @returns The resolved time to live or undefined if values should be kept in cache without time limit. + */ + protected async resolveArgsTtl( + provider: CacheTtlProvider | undefined, + args: TArgs + ): Promise { + if (provider == undefined) { + if (typeof this.defaultTtl === 'function') { + return await this.defaultTtl(); + } + return this.defaultTtl; + } + if (typeof provider === 'function') { + return await provider(...args); + } + return provider; + } + + /** + * Creates a cached value from the given input. + * @param value - The value of the value to cache. + * @param tags - The tags that should mark this cache entry. + * @param ttl - The time that this cache entry should be valid. + * @returns A cached value definition. + */ + protected createCachedValue(value: V, tags: CacheTag[], ttl: number | undefined): CachedValue { + const createdAt: Date = new Date(); + const expiresAt: Date | undefined = ttl != undefined ? new Date(createdAt.getTime() + ttl) : undefined; + + return { + createdAt, + expiresAt, + value, + tags + }; + } +} \ No newline at end of file diff --git a/src/caching/cache/cache-operation.enum.ts b/src/caching/cache/cache-operation.enum.ts new file mode 100644 index 0000000..355a4a4 --- /dev/null +++ b/src/caching/cache/cache-operation.enum.ts @@ -0,0 +1,9 @@ +/** + * The different operations of a cache. + */ +export enum CacheOperation { + WRAP = 'WRAP', + WRITE = 'WRITE', + DELETE = 'DELETE', + INVALIDATE = 'INVALIDATE' +} \ No newline at end of file diff --git a/src/caching/cache/cache-options.model.ts b/src/caching/cache/cache-options.model.ts new file mode 100644 index 0000000..ed6a79a --- /dev/null +++ b/src/caching/cache/cache-options.model.ts @@ -0,0 +1,121 @@ +/** + * Provider for a time to live value. + */ +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) | Promise); + +/** + * Tags derived from args only (no result available) — wrapDelete, wrapInvalidate. + */ +export type CacheTagsProvider = CacheTag[] + | ((...args: TArgs) => CacheTag[] | Promise); + +/** + * Tags derived from result + args — wrap, wrapWrite. + */ +export type ResultCacheTagsProvider = CacheTag[] + | ((result: TResult, ...args: TArgs) => CacheTag[] | Promise); + +/** + * Provider for a cache key. + */ +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 | 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. + */ +export type CacheWrapOptions = { + /** + * A provider for the time to live value. + */ + ttl?: ResultCacheTtlProvider, + /** + * A provider for the tags to set. + */ + tags?: ResultCacheTagsProvider +}; + +/** + * Options for the wrapWrite method of a cache when the result of the original function is available. + */ +export type CacheWrapWriteOptionsWithResult = { + /** + * A provider for the time to live value. + */ + ttl?: ResultCacheTtlProvider, + /** + * A provider for the tags to set. + */ + tags?: ResultCacheTagsProvider, + /** + * A provider for the tags to invalidate. + */ + invalidatesTags?: ResultCacheTagsProvider +}; + +/** + * Options for the wrapWrite method of a cache when the result of the original function is NOT available. + */ +export type CacheWrapWriteOptionsArgsOnly = { + /** + * A provider for the time to live value. + */ + ttl?: CacheTtlProvider, + /** + * A provider for the tags to set. + */ + tags?: CacheTagsProvider, + /** + * A provider for the tags to invalidate. + */ + invalidatesTags?: CacheTagsProvider +}; + +/** + * Options for the wrapDelete method of a cache. + */ +export type CacheWrapDeleteOptions = { + /** + * A provider for the tags to invalidate. + */ + invalidatesTags?: CacheTagsProvider +}; + +/** + * Options for the wrapInvalidate method of a cache. + */ +export type CacheWrapInvalidateOptions = { + /** + * A provider for the tags to invalidate. + */ + invalidatesTags: CacheTagsProvider +}; \ No newline at end of file diff --git a/src/caching/cache/cache.interface.ts b/src/caching/cache/cache.interface.ts new file mode 100644 index 0000000..df01f1f --- /dev/null +++ b/src/caching/cache/cache.interface.ts @@ -0,0 +1,133 @@ +import { type CacheTagMatcher } from '../cache-tag-matchers'; +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 { + /** + * Phantom carrier, only for type inference. + */ + readonly _writeResultAvailable: WriteResultAvailable, + /** + * The name of the cache. Should be unique. + */ + readonly name: N, + /** + * The tags that any values inside of this cache might have. + * + * This is used for performance improvements, to skip caches for invalidateTags if the given tag will never be inside any of the cached values. + * Can be set to 'all' to check every time. + */ + readonly tags: 'all' | readonly CacheTagMatcher[], + /** + * The default time to live for a cached value. + */ + readonly defaultTtl?: number | (() => number | Promise), + /** + * Whether to throw when invalidation fails or to just log and ignore. + */ + readonly onInvalidationFailure?: OnInvalidationFailure, + /** + * The store used by this cache. + */ + readonly store: CacheStoreInterface, + + /** + * Returns a cached value if present; otherwise calls fn. + * The exact caching behavior (whether the result is stored) depends on the concrete strategy. + */ + wrap: ( + fn: (...args: TArgs) => V | Promise, + keyFn: CacheKeyProvider, + options?: CacheWrapOptions + ) => (...args: TArgs) => Promise, + + /** + * Returns a wrapped version of fn that writes its result into the cache. + * Key is derived from the *result*, not the args — handles the + * create-with-generated-id case cleanly. + */ + wrapWrite: ( + fn: (...args: TArgs) => V | Promise, + keyFn: WriteResultAvailable extends true + ? ResultCacheKeyProvider + : CacheKeyProvider, + options?: WriteResultAvailable extends true + ? CacheWrapWriteOptionsWithResult + : CacheWrapWriteOptionsArgsOnly + ) => (...args: TArgs) => Promise, + + /** + * Returns a wrapped version of fn that invalidates a cache entry after call. + */ + wrapDelete: ( + fn: (...args: TArgs) => TReturn | Promise, + keyFn: CacheKeyProvider, + options?: CacheWrapDeleteOptions + ) => (...args: TArgs) => Promise, + + /** + * Calls fn → Invalidates tags → Returns the fn result. + * For operations that affect cached data but produce no cacheable result, + * e.g. CreateAll/updateAll on a collection cache where no filter key + * can be derived from the output. + */ + wrapInvalidate: ( + fn: (...args: TArgs) => TReturn | Promise, + options: CacheWrapInvalidateOptions // required — this method exists solely to invalidate + ) => (...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, stylistic/max-len +export type AnyCache = MultiTierCache | CacheInterface | CacheInterface; + +/** + * Checks whether or not the given value is a cache. + * @param value - The value to check. + * @returns True if the value has all the keys of a cache, false otherwise. + */ +export function isCache(value: unknown): value is AnyCache { + + if (value instanceof MultiTierCache) { + return true; + } + // eslint-disable-next-line typescript/no-explicit-any + const keys: (keyof ExcludeStrict>)[] = [ + 'defaultTtl', + 'name', + 'onInvalidationFailure', + 'store', + 'tags', + 'wrap', + 'wrapDelete', + 'wrapInvalidate', + 'wrapWrite' + ]; + + if (value == undefined || typeof value !== 'object') { + return false; + } + + for (const key of keys) { + if (!(key in value)) { + return false; + } + } + + return true; +} \ No newline at end of file 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..8efe345 --- /dev/null +++ b/src/caching/cache/multi-tier.cache.ts @@ -0,0 +1,298 @@ + +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; + // eslint-disable-next-line sonar/cognitive-complexity + 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 new file mode 100644 index 0000000..68f4bba --- /dev/null +++ b/src/caching/cache/read-aside/read-aside.cache.ts @@ -0,0 +1,99 @@ +import { AlsUtilities } from '../../../context/als.utilities'; +import { LogCacheContext } from '../../../logging/log-context.model'; +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'; + +/** + * Read‑aside base class. + * + * On a cache miss the source is called, but the result is **never** written + * back to the cache. Cache population happens only via explicit write + * strategies (`wrapWrite`) or out‑of‑band processes. + * + * `wrapDelete` and `wrapInvalidate` work exactly like their read‑through + * counterparts. + */ +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, + keyFn: CacheKeyProvider, + // eslint-disable-next-line unusedImports/no-unused-vars + options?: CacheWrapOptions + ): (...args: TArgs) => Promise { + return async (...args) => { + const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRAP }; + const label: Record = { cache: this.name }; + + return AlsUtilities.runWithCacheContext(cacheCtx, async () => { + let key: K | undefined; + + // ---------- try cache read ---------- + try { + key = await keyFn(...args); + cacheCtx.key = key; + + const storeStart: number = performance.now(); + const cached: CachedValue | undefined = await this.store.get(key); + this.metrics.storeDuration.observe( + { cache: this.name, operation: CacheOperation.WRAP }, + performance.now() - storeStart + ); + + if (cached !== undefined) { + // expired → evict, but still treat as miss + if (cached.expiresAt !== undefined && cached.expiresAt.getTime() <= Date.now()) { + this.metrics.expiredEvictions.increase(label); + await Promise.resolve() + .then(() => this.store.delete(key as K)) + .catch(() => undefined); + await this.updateSizeGauge(); + } + else { + cacheCtx.hit = true; + this.metrics.hits.increase(label); + return cached.value; + } + } + } + catch (error) { + this.metrics.errors.increase({ cache: this.name, operation: CacheOperation.WRAP }); + await this.logger.warn('Cache read failed, treating as miss', { error }); + } + + // ---------- cache miss ---------- + cacheCtx.hit = false; + this.metrics.misses.increase(label); + + if (key == undefined) { + const start: number = performance.now(); + const value: V = await fn(...args); + cacheCtx.durationInMs = performance.now() - start; + return value; + } + + // Single‑flight: coalesce concurrent source calls for the same key. + // No store.set – **read‑aside**. + return this.runSingleFlight(key, async () => { + const sourceStart: number = performance.now(); + const value: V = await fn(...args); + const sourceDuration: number = performance.now() - sourceStart; + cacheCtx.durationInMs = sourceDuration; + this.metrics.sourceDuration.observe( + { cache: this.name, operation: CacheOperation.WRAP }, + sourceDuration + ); + return value; + }); + }); + }; + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..caca068 --- /dev/null +++ b/src/caching/cache/read-aside/write-around-read-aside.cache.ts @@ -0,0 +1,52 @@ +import { AlsUtilities } from '../../../context/als.utilities'; +import { LogCacheContext } from '../../../logging/log-context.model'; +import { CacheOperation } from '../cache-operation.enum'; +import { CacheKeyProvider, CacheWrapWriteOptionsArgsOnly } from '../cache-options.model'; +import { CacheInterface } from '../cache.interface'; +import { ReadAsideCache } from './read-aside.cache'; + +/** + * Write‑around 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 { + + // 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 + options?: CacheWrapWriteOptionsArgsOnly + ): (...args: TArgs) => Promise { + return async (...args) => { + const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; + + return AlsUtilities.runWithCacheContext(cacheCtx, async () => { + const sourceStart: number = performance.now(); + const value: V = await fn(...args); + const sourceDuration: number = performance.now() - sourceStart; + cacheCtx.durationInMs = sourceDuration; + this.metrics.sourceDuration.observe( + { cache: this.name, operation: CacheOperation.WRITE }, + sourceDuration + ); + + await this.safeInvalidateTags(options?.invalidatesTags, args); + this.metrics.writes.increase({ cache: this.name }); + + return value; + }); + }; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async setDirect(): Promise { + // 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 new file mode 100644 index 0000000..fb56bd9 --- /dev/null +++ b/src/caching/cache/read-aside/write-behind-read-aside.cache.ts @@ -0,0 +1,95 @@ +import { AlsUtilities } from '../../../context/als.utilities'; +import { LogCacheContext } from '../../../logging/log-context.model'; +import { CacheOperation } from '../cache-operation.enum'; +import { ResultCacheKeyProvider, CacheWrapWriteOptionsWithResult, CacheSetDirectOptions } from '../cache-options.model'; +import { CacheInterface } from '../cache.interface'; +import { ReadAsideCache } from './read-aside.cache'; + +/** + * Write‑behind 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 { + + // eslint-disable-next-line jsdoc/require-jsdoc + readonly _writeResultAvailable: true = true; + + // eslint-disable-next-line jsdoc/require-jsdoc + wrapWrite( + fn: (...args: TArgs) => V | Promise, + keyFn: ResultCacheKeyProvider, + options?: CacheWrapWriteOptionsWithResult + ): (...args: TArgs) => Promise { + return async (...args) => { + const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; + + return AlsUtilities.runWithCacheContext(cacheCtx, async () => { + const sourceStart: number = performance.now(); + const value: V = await fn(...args); + const sourceDuration: number = performance.now() - sourceStart; + cacheCtx.durationInMs = sourceDuration; + this.metrics.sourceDuration.observe( + { cache: this.name, operation: CacheOperation.WRITE }, + sourceDuration + ); + + await this.safeInvalidateTags(options?.invalidatesTags, value, args); + + const key: K = await keyFn(value, ...args); + cacheCtx.key = key; + const [tags, ttl] = await Promise.all([ + this.resolveResultTags(options?.tags, value, args), + this.resolveResultTtl(options?.ttl, value, args) + ]); + + // Fire‑and‑forget write + 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', + { error } + ); + }); + + return value; + }); + }; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async setDirect(key: K, value: V, options?: CacheSetDirectOptions): 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 new file mode 100644 index 0000000..4035fb4 --- /dev/null +++ b/src/caching/cache/read-aside/write-invalidate-read-aside-args-only.cache.ts @@ -0,0 +1,84 @@ +import { AlsUtilities } from '../../../context/als.utilities'; +import { LogCacheContext } from '../../../logging/log-context.model'; +import { CacheOperation } from '../cache-operation.enum'; +import { CacheKeyProvider, CacheWrapWriteOptionsArgsOnly } from '../cache-options.model'; +import { CacheInterface } from '../cache.interface'; +import { ReadAsideCache } from './read-aside.cache'; + +/** + * Write‑invalidate (key from arguments) 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 { + + // 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, + options?: CacheWrapWriteOptionsArgsOnly + ): (...args: TArgs) => Promise { + return async (...args) => { + const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; + + return AlsUtilities.runWithCacheContext(cacheCtx, async () => { + const sourceStart: number = performance.now(); + const value: V = await fn(...args); + const sourceDuration: number = performance.now() - sourceStart; + cacheCtx.durationInMs = sourceDuration; + this.metrics.sourceDuration.observe( + { cache: this.name, operation: CacheOperation.WRITE }, + sourceDuration + ); + + await Promise.all([ + this.safeInvalidateTags(options?.invalidatesTags, args), + Promise.resolve() + .then(async () => { + const key: K = await keyFn(...args); + cacheCtx.key = key; + + 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(async (error) => { + this.metrics.errors.increase({ cache: this.name, operation: 'delete' }); + await this.logger.warn( + 'Cache invalidation (delete) failed after successful source write', + { error } + ); + }) + ]); + + return value; + }); + }; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async setDirect(key: K): Promise { + 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 new file mode 100644 index 0000000..075c31b --- /dev/null +++ b/src/caching/cache/read-aside/write-invalidate-read-aside-with-result.cache.ts @@ -0,0 +1,84 @@ +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 { CacheInterface } from '../cache.interface'; +import { ReadAsideCache } from './read-aside.cache'; + +/** + * Write‑invalidate (key from result) 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 { + + // eslint-disable-next-line jsdoc/require-jsdoc + readonly _writeResultAvailable: true = true; + + // eslint-disable-next-line jsdoc/require-jsdoc + wrapWrite( + fn: (...args: TArgs) => V | Promise, + keyFn: ResultCacheKeyProvider, + options?: CacheWrapWriteOptionsWithResult + ): (...args: TArgs) => Promise { + return async (...args) => { + const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; + + return AlsUtilities.runWithCacheContext(cacheCtx, async () => { + const sourceStart: number = performance.now(); + const value: V = await fn(...args); + const sourceDuration: number = performance.now() - sourceStart; + cacheCtx.durationInMs = sourceDuration; + this.metrics.sourceDuration.observe( + { cache: this.name, operation: CacheOperation.WRITE }, + sourceDuration + ); + + await Promise.all([ + this.safeInvalidateTags(options?.invalidatesTags, value, args), + Promise.resolve() + .then(async () => { + const key: K = await keyFn(value, ...args); + cacheCtx.key = key; + + 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(async (error) => { + this.metrics.errors.increase({ cache: this.name, operation: 'delete' }); + await this.logger.warn( + 'Cache invalidation (delete) failed after successful source write', + { error } + ); + }) + ]); + + return value; + }); + }; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async setDirect(key: K): Promise { + 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 new file mode 100644 index 0000000..e74994d --- /dev/null +++ b/src/caching/cache/read-aside/write-through-read-aside.cache.ts @@ -0,0 +1,86 @@ +import { AlsUtilities } from '../../../context/als.utilities'; +import { LogCacheContext } from '../../../logging/log-context.model'; +import { CacheOperation } from '../cache-operation.enum'; +import { ResultCacheKeyProvider, CacheWrapWriteOptionsWithResult, CacheSetDirectOptions } from '../cache-options.model'; +import { CacheInterface } from '../cache.interface'; +import { ReadAsideCache } from './read-aside.cache'; + +/** + * Write‑through 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 { + + // eslint-disable-next-line jsdoc/require-jsdoc + readonly _writeResultAvailable: true = true; + + // eslint-disable-next-line jsdoc/require-jsdoc + wrapWrite( + fn: (...args: TArgs) => V | Promise, + keyFn: ResultCacheKeyProvider, + options?: CacheWrapWriteOptionsWithResult + ): (...args: TArgs) => Promise { + return async (...args) => { + const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; + + return AlsUtilities.runWithCacheContext(cacheCtx, async () => { + const sourceStart: number = performance.now(); + const value: V = await fn(...args); + const sourceDuration: number = performance.now() - sourceStart; + cacheCtx.durationInMs = sourceDuration; + this.metrics.sourceDuration.observe( + { cache: this.name, operation: CacheOperation.WRITE }, + sourceDuration + ); + + await this.safeInvalidateTags(options?.invalidatesTags, value, args); + + try { + const key: K = await keyFn(value, ...args); + cacheCtx.key = key; + 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)); + this.metrics.storeDuration.observe( + { cache: this.name, operation: 'set' }, + performance.now() - storeStart + ); + 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 after successful source write, source write was not rolled back', + { error } + ); + } + + return value; + }); + }; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async setDirect(key: K, value: V, options?: CacheSetDirectOptions): 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 new file mode 100644 index 0000000..c3b5353 --- /dev/null +++ b/src/caching/cache/read-through/read-through.cache.ts @@ -0,0 +1,104 @@ +import { AlsUtilities } from '../../../context/als.utilities'; +import { LogCacheContext } from '../../../logging/log-context.model'; +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'; + +/** + * Base class for all read through caches. + */ +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, + keyFn: CacheKeyProvider, + options?: CacheWrapOptions + ): (...args: TArgs) => Promise { + return async (...args) => { + const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRAP }; + const label: Record = { cache: this.name }; + + return AlsUtilities.runWithCacheContext(cacheCtx, async () => { + let key: K | undefined; + + try { + key = await keyFn(...args); + cacheCtx.key = key; + + const storeStart: number = performance.now(); + const cached: CachedValue | undefined = await this.store.get(key); + this.metrics.storeDuration.observe( + { cache: this.name, operation: CacheOperation.WRAP }, + performance.now() - storeStart + ); + + if (cached !== undefined) { + if (cached.expiresAt !== undefined && cached.expiresAt.getTime() <= Date.now()) { + this.metrics.expiredEvictions.increase(label); + await Promise.resolve() + .then(() => this.store.delete(key as K)) + .catch(() => undefined); + await this.updateSizeGauge(); + } + else { + cacheCtx.hit = true; + this.metrics.hits.increase(label); + return cached.value; + } + } + } + catch (error) { + this.metrics.errors.increase({ cache: this.name, operation: CacheOperation.WRAP }); + await this.logger.warn('Cache read failed, treating as miss', { error }); + } + + cacheCtx.hit = false; + this.metrics.misses.increase(label); + + if (key == undefined) { + const start: number = performance.now(); + const value: V = await fn(...args); + cacheCtx.durationInMs = performance.now() - start; + return value; + } + + return this.runSingleFlight(key, async () => { + const sourceStart: number = performance.now(); + const value: V = await fn(...args); + const sourceDuration: number = performance.now() - sourceStart; + cacheCtx.durationInMs = sourceDuration; + this.metrics.sourceDuration.observe({ cache: this.name, operation: CacheOperation.WRAP }, sourceDuration); + + try { + 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)); + this.metrics.storeDuration.observe({ cache: this.name, operation: 'set' }, performance.now() - storeStart); + + await this.updateSizeGauge(); + } + catch (error) { + this.metrics.errors.increase({ cache: this.name, operation: 'set' }); + await this.logger.warn( + 'Cache store failed after successful source read, value was returned but not cached', + { error } + ); + } + + return value; + }); + }); + }; + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..48be821 --- /dev/null +++ b/src/caching/cache/read-through/write-around-read-through.cache.ts @@ -0,0 +1,51 @@ +import { AlsUtilities } from '../../../context/als.utilities'; +import { LogCacheContext } from '../../../logging/log-context.model'; +import { CacheOperation } from '../cache-operation.enum'; +import { CacheKeyProvider, CacheWrapWriteOptionsArgsOnly } from '../cache-options.model'; +import { CacheInterface } from '../cache.interface'; +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 { + + // 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, + options?: CacheWrapWriteOptionsArgsOnly + ): (...args: TArgs) => Promise { + return async (...args) => { + const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; + + return AlsUtilities.runWithCacheContext(cacheCtx, async () => { + const sourceStart: number = performance.now(); + const value: V = await fn(...args); + const sourceDuration: number = performance.now() - sourceStart; + + cacheCtx.durationInMs = sourceDuration; + this.metrics.sourceDuration.observe( + { cache: this.name, operation: CacheOperation.WRITE }, + sourceDuration + ); + + await this.safeInvalidateTags(options?.invalidatesTags, args); + this.metrics.writes.increase({ cache: this.name }); + + return value; + }); + }; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async setDirect(): Promise { + // 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 new file mode 100644 index 0000000..11c6ff7 --- /dev/null +++ b/src/caching/cache/read-through/write-behind-read-through.cache.ts @@ -0,0 +1,99 @@ +import { AlsUtilities } from '../../../context/als.utilities'; +import { LogCacheContext } from '../../../logging/log-context.model'; +import { CacheOperation } from '../cache-operation.enum'; +import { ResultCacheKeyProvider, CacheWrapWriteOptionsWithResult, CacheSetDirectOptions } from '../cache-options.model'; +import { CacheInterface } from '../cache.interface'; +import { ReadThroughCache } from './read-through.cache'; + +/** + * Write‑behind read‑through cache. + * + * Writes are applied to the source immediately, then the cache is updated + * **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 { + + // eslint-disable-next-line jsdoc/require-jsdoc + readonly _writeResultAvailable: true = true; + + // eslint-disable-next-line jsdoc/require-jsdoc + wrapWrite( + fn: (...args: TArgs) => V | Promise, + keyFn: ResultCacheKeyProvider, + options?: CacheWrapWriteOptionsWithResult + ): (...args: TArgs) => Promise { + return async (...args) => { + const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; + + return AlsUtilities.runWithCacheContext(cacheCtx, async () => { + const sourceStart: number = performance.now(); + const value: V = await fn(...args); + const sourceDuration: number = performance.now() - sourceStart; + cacheCtx.durationInMs = sourceDuration; + this.metrics.sourceDuration.observe( + { cache: this.name, operation: CacheOperation.WRITE }, + sourceDuration + ); + + // Invalidate tags (safe, awaited because it affects other caches) + await this.safeInvalidateTags(options?.invalidatesTags, value, args); + + // Derive the cache key from the result (same as write‑through) + const key: K = await keyFn(value, ...args); + cacheCtx.key = key; + + const [tags, ttl] = await Promise.all([ + this.resolveResultTags(options?.tags, value, args), + this.resolveResultTtl(options?.ttl, value, args) + ]); + + // ----- fire‑and‑forget cache write ----------------------------------- + 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', + { error } + ); + }); + + return value; + }); + }; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async setDirect(key: K, value: V, options?: CacheSetDirectOptions): 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 new file mode 100644 index 0000000..10b5a20 --- /dev/null +++ b/src/caching/cache/read-through/write-invalidate-read-through-args-only.cache.ts @@ -0,0 +1,86 @@ +import { AlsUtilities } from '../../../context/als.utilities'; +import { LogCacheContext } from '../../../logging/log-context.model'; +import { CacheOperation } from '../cache-operation.enum'; +import { CacheKeyProvider, CacheWrapWriteOptionsArgsOnly } from '../cache-options.model'; +import { CacheInterface } from '../cache.interface'; +import { ReadThroughCache } from './read-through.cache'; + +/** + * Write‑invalidate (key from arguments) read‑through cache. + * + * After the source write succeeds, the cache entry identified by the + * argument‑derived key is **deleted**, and optional tag invalidations + * are fired. + */ +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( + fn: (...args: TArgs) => V | Promise, + keyFn: CacheKeyProvider, + options?: CacheWrapWriteOptionsArgsOnly + ): (...args: TArgs) => Promise { + return async (...args) => { + const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; + + return AlsUtilities.runWithCacheContext(cacheCtx, async () => { + const sourceStart: number = performance.now(); + const value: V = await fn(...args); + const sourceDuration: number = performance.now() - sourceStart; + cacheCtx.durationInMs = sourceDuration; + this.metrics.sourceDuration.observe( + { cache: this.name, operation: CacheOperation.WRITE }, + sourceDuration + ); + + // Invalidate tags and delete the specific key, in parallel + await Promise.all([ + this.safeInvalidateTags(options?.invalidatesTags, args), + Promise.resolve() + .then(async () => { + const key: K = await keyFn(...args); + cacheCtx.key = key; + + 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(async (error) => { + this.metrics.errors.increase({ cache: this.name, operation: 'delete' }); + await this.logger.warn( + 'Cache invalidation (delete) failed after successful source write', + { error } + ); + }) + ]); + + return value; + }); + }; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async setDirect(key: K): Promise { + 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 new file mode 100644 index 0000000..6fa8e5d --- /dev/null +++ b/src/caching/cache/read-through/write-invalidate-read-through-with-result.cache.ts @@ -0,0 +1,85 @@ +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 { CacheInterface } from '../cache.interface'; +import { ReadThroughCache } from './read-through.cache'; + +/** + * Write‑invalidate (key from result) 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 { + + // eslint-disable-next-line jsdoc/require-jsdoc + readonly _writeResultAvailable: true = true; + + // eslint-disable-next-line jsdoc/require-jsdoc + wrapWrite( + fn: (...args: TArgs) => V | Promise, + keyFn: ResultCacheKeyProvider, + options?: CacheWrapWriteOptionsWithResult + ): (...args: TArgs) => Promise { + return async (...args) => { + const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; + + return AlsUtilities.runWithCacheContext(cacheCtx, async () => { + const sourceStart: number = performance.now(); + const value: V = await fn(...args); + const sourceDuration: number = performance.now() - sourceStart; + cacheCtx.durationInMs = sourceDuration; + this.metrics.sourceDuration.observe( + { cache: this.name, operation: CacheOperation.WRITE }, + sourceDuration + ); + + // Invalidate tags and delete the result‑based key + await Promise.all([ + this.safeInvalidateTags(options?.invalidatesTags, value, args), + Promise.resolve() + .then(async () => { + const key: K = await keyFn(value, ...args); + cacheCtx.key = key; + + 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(async (error) => { + this.metrics.errors.increase({ cache: this.name, operation: 'delete' }); + await this.logger.warn( + 'Cache invalidation (delete) failed after successful source write', + { error } + ); + }) + ]); + + return value; + }); + }; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async setDirect(key: K): Promise { + 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 new file mode 100644 index 0000000..859f46c --- /dev/null +++ b/src/caching/cache/read-through/write-through-read-through.cache.test.ts @@ -0,0 +1,520 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; + +import { WriteThroughReadThroughCache } from './write-through-read-through.cache'; +import { flushMicrotasks } from '../../../__testing__/constants'; +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 { Ms } from '../../../utilities/ms'; +import { type CacheServiceInterface } from '../../cache-service.interface'; +import { CacheService } from '../../cache.service'; +import { Cache } from '../../decorators/cache.decorator'; +import { CachedValue } from '../../store/cached-value.model'; +import { InMemoryCacheStore } from '../../store/in-memory.cache-store'; + +@Cache() +class TestCache 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('Test Cache', new InMemoryCacheStore(), 'all'); + } +} + +@Cache() +class MinuteTtlTestCache 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('Minute TTL Test Cache', new InMemoryCacheStore(), 'all', Ms.MINUTE); + } +} + +@Cache() +class MinuteTtlIdTestCache 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('Minute TTL Id Test Cache', new InMemoryCacheStore(), 'all', Ms.MINUTE); + } +} + +@Cache() +class MainCacheTestCache 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('Main Cache Test Cache', new InMemoryCacheStore(), 'all'); + } +} + +@Cache() +class AffectedCache1TestCache 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('Affected Cache 1 Test Cache', new InMemoryCacheStore(), ['invalidate-me']); + } +} + +@Cache() +class UnaffectedCache1TestCache 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('Unaffected Cache 1 Test Cache', new InMemoryCacheStore(), ['leave-me']); + } +} + +@Cache() +class OwnCacheTestCache 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('Own Cache Test Cache', new InMemoryCacheStore(), 'all'); + } +} + +@Cache() +class UnaffectedCache2TestCache 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('Unaffected Cache 2 Test Cache', new InMemoryCacheStore(), ['keep-me']); + } +} + +@Cache() +class AbcCacheTestCache 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('Abc Cache Test Cache', new InMemoryCacheStore(), ['tag:abc']); + } +} + +function createCachedValue( + value: V, + tags: string[], + createdAt: Date, + expiresAt?: Date +): CachedValue { + return { + value, + tags, + createdAt, + expiresAt + }; +} + +describe('WriteThroughReadThroughCache', () => { + let server: StartedTestServer; + let cacheService: CacheService; + + beforeAll(async () => { + server = await startTestServer({}); + cacheService = inject(ZIBRI_DI_TOKENS.CACHE_SERVICE) as CacheService; + }, 15000); + + afterAll(async () => { + await server?.shutdown(); + }); + + beforeEach(() => { + cacheService.caches.length = 0; + }); + + afterEach(async () => { + await inject(TestCache).store.clear(); + await inject(MinuteTtlIdTestCache).store.clear(); + await inject(MinuteTtlTestCache).store.clear(); + await inject(MainCacheTestCache).store.clear(); + await inject(AffectedCache1TestCache).store.clear(); + await inject(UnaffectedCache1TestCache).store.clear(); + await inject(OwnCacheTestCache).store.clear(); + await inject(UnaffectedCache2TestCache).store.clear(); + }); + + it('wrap returns a cached value without calling fn', async () => { + const cache: WriteThroughReadThroughCache = inject(TestCache); + await cache.store.set('key:a', createCachedValue(123, ['tag-a'], new Date())); + + // eslint-disable-next-line typescript/typedef, unusedImports/no-unused-vars + const fn = jest.fn((_id: string): number => 999); + // eslint-disable-next-line typescript/typedef + const wrapped = cache.wrap(fn, (id) => `key:${id}`); + + await expect(wrapped('a')).resolves.toBe(123); + expect(fn).not.toHaveBeenCalled(); + }); + + it('wrap deletes expired cached values and recomputes', async () => { + const cache: WriteThroughReadThroughCache = inject(TestCache); + + await cache.store.set( + 'key:a', + createCachedValue(123, ['tag-a'], new Date('2025-01-01T00:00:00.000Z'), new Date('2025-01-01T00:00:01.000Z')) + ); + + // eslint-disable-next-line typescript/typedef + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(new Date('2025-01-01T00:00:02.000Z').getTime()); + // eslint-disable-next-line typescript/typedef, unusedImports/no-unused-vars + const fn = jest.fn((_id: string): number => 999); + // eslint-disable-next-line typescript/typedef + const wrapped = cache.wrap(fn, (id) => `key:${id}`); + + await expect(wrapped('a')).resolves.toBe(999); + + expect(fn).toHaveBeenCalledTimes(1); + expect((await cache.store.get('key:a'))?.value).toBe(999); + + nowSpy.mockRestore(); + }); + + it('wrap stores a miss with resolved tags and default ttl', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + + try { + const cache: MinuteTtlIdTestCache = inject(MinuteTtlIdTestCache); + + // eslint-disable-next-line typescript/typedef + const fn = (name: string): { id: string } => ({ id: `id:${name}` }); + // eslint-disable-next-line typescript/typedef + const wrapped = cache.wrap( + fn, + (name) => `key:${name}`, + { + tags: (result, name) => [`tag:${result.id}`, `name:${name}`] + } + ); + + await expect(wrapped('abc')).resolves.toEqual({ id: 'id:abc' }); + + const cached: CachedValue<{ id: string }> | undefined = await cache.store.get('key:abc'); + expect(cached).toBeDefined(); + expect(cached?.value).toEqual({ id: 'id:abc' }); + expect(cached?.tags).toEqual(['tag:id:abc', 'name:abc']); + expect(cached?.createdAt).toEqual(new Date('2025-01-01T00:00:00.000Z')); + expect(cached?.expiresAt).toEqual(new Date('2025-01-01T00:01:00.000Z')); + } + finally { + jest.useRealTimers(); + } + }); + + it('wrap uses ttl from options over default ttl', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + + try { + const cache: MinuteTtlTestCache = inject(MinuteTtlTestCache); + + // eslint-disable-next-line typescript/typedef + const fn = (id: string): number => id.length; + // eslint-disable-next-line typescript/typedef + const wrapped = cache.wrap( + fn, + (id) => `key:${id}`, + { + ttl: Ms.SECOND * 5 + } + ); + + await expect(wrapped('abc')).resolves.toBe(3); + + const cached: CachedValue | undefined = await cache.store.get('key:abc'); + expect(cached?.expiresAt).toEqual(new Date('2025-01-01T00:00:05.000Z')); + } + finally { + jest.useRealTimers(); + } + }); + + it('wrapWrite invalidates matching caches and writes the result', async () => { + const mainCache: MainCacheTestCache = inject(MainCacheTestCache); + const affectedCache: AffectedCache1TestCache = inject(AffectedCache1TestCache); + const unaffectedCache: UnaffectedCache1TestCache = inject(UnaffectedCache1TestCache); + + cacheService.caches.push(mainCache, affectedCache, unaffectedCache); + + await affectedCache.store.set('a1', createCachedValue('affected', ['invalidate-me'], new Date())); + await unaffectedCache.store.set('b1', createCachedValue('safe', ['leave-me'], new Date())); + + // eslint-disable-next-line typescript/typedef + const fn = (name: string): { id: string } => ({ id: `id:${name}` }); + // eslint-disable-next-line typescript/typedef + const wrapped = mainCache.wrapWrite( + fn, + (result, name) => `${result.id}:${name}`, + { + invalidatesTags: ['invalidate-me'], + tags: (result, name) => [`written:${result.id}`, `name:${name}`] + } + ); + + await expect(wrapped('abc')).resolves.toEqual({ id: 'id:abc' }); + + expect((await mainCache.store.get('id:abc:abc'))?.value).toEqual({ id: 'id:abc' }); + expect((await mainCache.store.get('id:abc:abc'))?.tags).toEqual(['written:id:abc', 'name:abc']); + expect(await affectedCache.store.get('a1')).toBeUndefined(); + expect((await unaffectedCache.store.get('b1'))?.value).toBe('safe'); + }); + + it('wrapWrite uses ttl from options over default ttl', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + + try { + const cache: MinuteTtlIdTestCache = inject(MinuteTtlIdTestCache); + + // eslint-disable-next-line typescript/typedef + const fn = (name: string): { id: string } => ({ id: `id:${name}` }); + // eslint-disable-next-line typescript/typedef + const wrapped = cache.wrapWrite( + fn, + (result, name) => `${result.id}:${name}`, + { + ttl: (result, name) => result.id === `id:${name}` ? Ms.SECOND * 10 : Ms.SECOND + } + ); + + await expect(wrapped('abc')).resolves.toEqual({ id: 'id:abc' }); + + const cached: CachedValue<{ id: string }> | undefined = await cache.store.get('id:abc:abc'); + expect(cached?.expiresAt).toEqual(new Date('2025-01-01T00:00:10.000Z')); + } + finally { + jest.useRealTimers(); + } + }); + + it('wrapDelete deletes its own key and invalidates matching caches', async () => { + const cache: OwnCacheTestCache = inject(OwnCacheTestCache); + const affectedCache: AffectedCache1TestCache = inject(AffectedCache1TestCache); + const unaffectedCache: UnaffectedCache2TestCache = inject(UnaffectedCache2TestCache); + + cacheService.caches.push(cache, affectedCache, unaffectedCache); + + await cache.store.set('key:1', createCachedValue(undefined, ['own'], new Date())); + await affectedCache.store.set('a1', createCachedValue('affected', ['invalidate-me'], new Date())); + await unaffectedCache.store.set('b1', createCachedValue('safe', ['keep-me'], new Date())); + + // eslint-disable-next-line typescript/typedef, unusedImports/no-unused-vars + const fn = jest.fn((_id: string): undefined => undefined); + // eslint-disable-next-line typescript/typedef + const wrapped = cache.wrapDelete( + fn, + (id: string) => `key:${id}`, + { + invalidatesTags: ['invalidate-me'] + } + ); + + await expect(wrapped('1')).resolves.toBeUndefined(); + + expect(fn).toHaveBeenCalledWith('1'); + expect(await cache.store.get('key:1')).toBeUndefined(); + expect(await affectedCache.store.get('a1')).toBeUndefined(); + expect((await unaffectedCache.store.get('b1'))?.value).toBe('safe'); + }); + + it('wrapInvalidate calls fn and then invalidates tags', async () => { + const cache: TestCache = inject(TestCache); + const affectedCache: AffectedCache1TestCache = inject(AffectedCache1TestCache); + + cacheService.caches.push(cache, affectedCache); + + await affectedCache.store.set('a1', createCachedValue('affected', ['invalidate-me'], new Date())); + + // eslint-disable-next-line typescript/typedef + const fn = jest.fn((id: string): number => id.length); + // eslint-disable-next-line typescript/typedef + const wrapped = cache.wrapInvalidate(fn, { invalidatesTags: ['invalidate-me'] }); + + await expect(wrapped('abc')).resolves.toBe(3); + + expect(fn).toHaveBeenCalledWith('abc'); + expect(await affectedCache.store.get('a1')).toBeUndefined(); + }); + + it('wrapInvalidate supports tag providers based on args', async () => { + const cache: TestCache = inject(TestCache); + + const affectedCache: AbcCacheTestCache = inject(AbcCacheTestCache); + cacheService.caches.push(cache, affectedCache); + + await affectedCache.store.set('a1', createCachedValue('affected', ['tag:abc'], new Date())); + + // eslint-disable-next-line typescript/typedef + const fn = (id: string): number => id.length; + // eslint-disable-next-line typescript/typedef + const wrapped = cache.wrapInvalidate(fn, { invalidatesTags: (id: string) => [`tag:${id}`] }); + + await expect(wrapped('abc')).resolves.toBe(3); + + expect(await affectedCache.store.get('a1')).toBeUndefined(); + }); + + it('dedupes concurrent wrap calls for the same key', async () => { + const cache: WriteThroughReadThroughCache = inject(TestCache); + + let resolveFn!: (value: number) => void; + // eslint-disable-next-line typescript/typedef, unusedImports/no-unused-vars + const fn = jest.fn((_id: string) => new Promise(resolve => { + resolveFn = resolve; + })); + + // eslint-disable-next-line typescript/typedef + const wrapped = cache.wrap(fn, (id) => `key:${id}`); + // eslint-disable-next-line typescript/typedef + const first = wrapped('a'); + // eslint-disable-next-line typescript/typedef + const second = wrapped('a'); + + await flushMicrotasks(); + + expect(fn).toHaveBeenCalledTimes(1); + + resolveFn(123); + + await expect(first).resolves.toBe(123); + await expect(second).resolves.toBe(123); + expect((await cache.store.get('key:a'))?.value).toBe(123); + }); + + it('does not dedupe concurrent wrap calls for different keys', async () => { + const cache: WriteThroughReadThroughCache = inject(TestCache); + + let resolveA!: (value: number) => void; + let resolveB!: (value: number) => void; + + // eslint-disable-next-line typescript/typedef + const fn = jest.fn((id: string) => new Promise(resolve => { + if (id === 'a') { + resolveA = resolve; + return; + } + + resolveB = resolve; + })); + + // eslint-disable-next-line typescript/typedef + const wrapped = cache.wrap(fn, (id: string) => `key:${id}`); + // eslint-disable-next-line typescript/typedef + const first = wrapped('a'); + // eslint-disable-next-line typescript/typedef + const second = wrapped('b'); + + await flushMicrotasks(); + + expect(fn).toHaveBeenCalledTimes(2); + + resolveA(1); + resolveB(2); + + await expect(first).resolves.toBe(1); + await expect(second).resolves.toBe(2); + + expect((await cache.store.get('key:a'))?.value).toBe(1); + expect((await cache.store.get('key:b'))?.value).toBe(2); + }); + + it('clears the in-flight entry after a rejected wrap call', async () => { + const cache: WriteThroughReadThroughCache = inject(TestCache); + + // eslint-disable-next-line typescript/typedef, unusedImports/no-unused-vars + const fn = jest.fn((_id: string): number => { + throw new Error('boom'); + }); + + // eslint-disable-next-line typescript/typedef + const wrapped = cache.wrap(fn, (id) => `key:${id}`); + + await expect(wrapped('a')).rejects.toThrow('boom'); + await expect(wrapped('a')).rejects.toThrow('boom'); + + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('returns cached values immediately after the first concurrent wrap completes', async () => { + const cache: WriteThroughReadThroughCache = inject(TestCache); + + let resolveFn!: (value: number) => void; + // eslint-disable-next-line typescript/typedef, unusedImports/no-unused-vars + const fn = jest.fn((_id: string) => new Promise(resolve => { + resolveFn = resolve; + })); + + // eslint-disable-next-line typescript/typedef + const wrapped = cache.wrap(fn, (id) => `key:${id}`); + // eslint-disable-next-line typescript/typedef + const first = wrapped('a'); + // eslint-disable-next-line typescript/typedef + const second = wrapped('a'); + + await flushMicrotasks(); + + resolveFn(123); + + await expect(first).resolves.toBe(123); + await expect(second).resolves.toBe(123); + + // eslint-disable-next-line typescript/typedef + const third = wrapped('a'); + + await flushMicrotasks(); + + await expect(third).resolves.toBe(123); + + expect(fn).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file 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 new file mode 100644 index 0000000..4737a99 --- /dev/null +++ b/src/caching/cache/read-through/write-through-read-through.cache.ts @@ -0,0 +1,77 @@ +import { AlsUtilities } from '../../../context/als.utilities'; +import { LogCacheContext } from '../../../logging/log-context.model'; +import { CacheOperation } from '../cache-operation.enum'; +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 { + + // eslint-disable-next-line jsdoc/require-jsdoc + readonly _writeResultAvailable: true = true; + + // eslint-disable-next-line jsdoc/require-jsdoc + wrapWrite( + fn: (...args: TArgs) => V | Promise, + keyFn: ResultCacheKeyProvider, + options?: CacheWrapWriteOptionsWithResult + ): (...args: TArgs) => Promise { + return async (...args) => { + const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; + + return AlsUtilities.runWithCacheContext(cacheCtx, async () => { + const sourceStart: number = performance.now(); + const value: V = await fn(...args); + const sourceDuration: number = performance.now() - sourceStart; + cacheCtx.durationInMs = sourceDuration; + this.metrics.sourceDuration.observe({ cache: this.name, operation: CacheOperation.WRITE }, sourceDuration); + + await this.safeInvalidateTags(options?.invalidatesTags, value, args); + + try { + const key: K = await keyFn(value, ...args); + cacheCtx.key = key; + 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)); + this.metrics.storeDuration.observe({ cache: this.name, operation: 'set' }, performance.now() - storeStart); + 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 after successful source write, source write was not rolled back', + { error } + ); + } + + return value; + }); + }; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async setDirect(key: K, value: V, options?: CacheSetDirectOptions): 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 new file mode 100644 index 0000000..bf4d810 --- /dev/null +++ b/src/caching/decorators/cache-delete.decorator.ts @@ -0,0 +1,46 @@ +import { inject } from '../../di/inject.function'; +import { DiToken } from '../../di/models/di-token.model'; +import { CacheKeyProvider, CacheWrapDeleteOptions } from '../cache/cache-options.model'; + +// eslint-disable-next-line jsdoc/require-returns +/** + * Marks the method to delete a cached value under the resolved key using the provided cache. + * @param cacheToken - The token of the cache to use. + * @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< + // 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 +) { + return ( + target: object, + propertyKey: string | symbol, + descriptor: 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 { + if (!wrappedFns.has(this)) { + // 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 await wrappedFns.get(this)!(...args); + }; + + return descriptor; + }; +} \ No newline at end of file diff --git a/src/caching/decorators/cache-invalidate.decorator.ts b/src/caching/decorators/cache-invalidate.decorator.ts new file mode 100644 index 0000000..f00110a --- /dev/null +++ b/src/caching/decorators/cache-invalidate.decorator.ts @@ -0,0 +1,43 @@ +import { inject } from '../../di/inject.function'; +import { DiToken } from '../../di/models/di-token.model'; +import { CacheWrapInvalidateOptions } from '../cache/cache-options.model'; + +// eslint-disable-next-line jsdoc/require-returns +/** + * Marks the method to invalidate some cached values resolved by the provided options using the provided cache. + * @param cacheToken - The token of the cache to use. + * @param options - Additional options, including tags to invalidate. + */ +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 ( + target: object, + propertyKey: string | symbol, + descriptor: 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 { + if (!wrappedFns.has(this)) { + // 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 + return wrappedFns.get(this)!(...args); + }; + + return descriptor; + }; +} \ No newline at end of file diff --git a/src/caching/decorators/cache-write.decorator.ts b/src/caching/decorators/cache-write.decorator.ts new file mode 100644 index 0000000..b628a8d --- /dev/null +++ b/src/caching/decorators/cache-write.decorator.ts @@ -0,0 +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. + */ +// 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> { + // 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 { + 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) + ); + } + // eslint-disable-next-line typescript/no-non-null-assertion + return wrappedFns.get(this)!(...args); + }; + + return descriptor; + }; +} \ No newline at end of file diff --git a/src/caching/decorators/cache.decorator.ts b/src/caching/decorators/cache.decorator.ts new file mode 100644 index 0000000..8e85376 --- /dev/null +++ b/src/caching/decorators/cache.decorator.ts @@ -0,0 +1,20 @@ +import { GlobalRegistry } from '../../global/global-registry'; +import { Newable } from '../../types/newable.type'; +import { MetadataUtilities } from '../../utilities/metadata.utilities'; +import { AnyCache } from '../cache/cache.interface'; + +/** + * Marks a class that should be used as a cache. + */ +export function Cache(): ClassDecorator { + return target => { + // eslint-disable-next-line unicorn/error-message + const stack: string = new Error().stack ?? ''; + MetadataUtilities.setFilePath(target, stack); + GlobalRegistry.injectables.push({ + token: target as unknown as Newable, + useClass: 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 new file mode 100644 index 0000000..e3662a5 --- /dev/null +++ b/src/caching/decorators/cached.decorator.ts @@ -0,0 +1,52 @@ +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 +/** + * Marks the method for caching via the given cache token. + * @param cacheToken - The token of the cache to use. + * @param keyFn - How to resolve the key under which results are cached. + * @param options - Additional options like ttl or tags. + */ +// 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?: ExtractWrapOptions +) { + return ( + target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor<(...args: TArgs) => Promise> + ): TypedPropertyDescriptor<(...args: TArgs) => Promise> => { + // eslint-disable-next-line typescript/no-non-null-assertion + 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)) { + // 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 await wrappedFns.get(this)!(...args); + }; + + return descriptor; + }; +} \ No newline at end of file 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/cache-store.interface.ts b/src/caching/store/cache-store.interface.ts new file mode 100644 index 0000000..f560820 --- /dev/null +++ b/src/caching/store/cache-store.interface.ts @@ -0,0 +1,59 @@ +import { CachedValue } from './cached-value.model'; + +/** + * The different strategies on how to handle cache overflows. + */ +export type RemoveOnOverflowStrategy = 'leastRecentlyUsed' | 'mostRecentlyUsed' | 'leastFrequentlyUsed' | 'firstInFirstOut'; + +/** + * Configuration of a cache store. + */ +export type CacheStoreConfig = { + /** + * The maximum amount of entries that the store can hold. + */ + readonly maxEntries: number, + /** + * The maximum amount of bytes that this store can hold. + * Checks are done usually done using estimations, not exact numbers. + */ + readonly maxBytes: number, + /** + * How to handle caches that exceed their maxEntries or maxBytes. + */ + readonly removeOnOverflow: RemoveOnOverflowStrategy +}; + +/** + * Interface for an cache store. + */ +export interface CacheStoreInterface { + /** + * The configuration for the store. + */ + readonly config: CacheStoreConfig, + /** + * Gets the cached value stored under the given key. + */ + get: (key: K) => CachedValue | undefined | Promise | undefined>, + /** + * Stores a new cached value under the given key. + */ + set: (key: K, value: CachedValue) => void | Promise, + /** + * Deletes the cached value stored under the given key. + */ + delete: (key: K) => void | Promise, + /** + * Completely clears all values from the cache. + */ + clear: () => void | Promise, + /** + * Invalidates all cached values that have one of the given tags. + */ + invalidateTags: (tags: string[]) => void | Promise, + /** + * The number of elements in the store. + */ + size: () => number | Promise +} \ No newline at end of file diff --git a/src/caching/store/cached-value.model.ts b/src/caching/store/cached-value.model.ts new file mode 100644 index 0000000..e70a2bd --- /dev/null +++ b/src/caching/store/cached-value.model.ts @@ -0,0 +1,27 @@ +import { Property } from '../../entity/decorators/property.decorator'; + +/** + * Definition for a cached value. + */ +export class CachedValue { + /** + * The timestamp at which the value has been cached. + */ + @Property.date({ default: () => new Date() }) + createdAt!: Date; + /** + * The timestamp at which this cached value expires. + */ + @Property.date({ required: false }) + expiresAt?: Date; + /** + * The actual value that has been cached. + */ + @Property.unknown({ required: false }) + value!: V; + /** + * Optional tags that the value can have and can be invalidated by. + */ + @Property.array({ items: { type: 'string' } }) + tags!: string[]; +} \ No newline at end of file diff --git a/src/caching/store/in-memory.cache-store.test.ts b/src/caching/store/in-memory.cache-store.test.ts new file mode 100644 index 0000000..5fa69ef --- /dev/null +++ b/src/caching/store/in-memory.cache-store.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from '@jest/globals'; + +import { CachedValue } from './cached-value.model'; +import { InMemoryCacheStore } from './in-memory.cache-store'; + +function createCachedValue(value: V, tags: string[]): CachedValue { + return { + value, + tags, + createdAt: new Date() + }; +} + +describe('InMemoryCacheStore', () => { + it('returns undefined for missing keys', () => { + const store: InMemoryCacheStore = new InMemoryCacheStore(); + + expect(store.get('missing')).toBeUndefined(); + expect(store.has('missing')).toBe(false); + }); + + it('stores and retrieves values', () => { + const store: InMemoryCacheStore = new InMemoryCacheStore(); + + store.set('a', createCachedValue(1, ['tag-a'])); + + expect(store.get('a')?.value).toBe(1); + expect(store.get('a')?.tags).toEqual(['tag-a']); + expect(store.has('a')).toBe(true); + }); + + it('overwrites existing values for the same key', () => { + const store: InMemoryCacheStore = new InMemoryCacheStore(); + + store.set('a', createCachedValue(1, ['tag-a'])); + store.set('a', createCachedValue(2, ['tag-b'])); + + expect(store.get('a')?.value).toBe(2); + expect(store.get('a')?.tags).toEqual(['tag-b']); + expect(store.has('a')).toBe(true); + }); + + it('deletes values by key', () => { + const store: InMemoryCacheStore = new InMemoryCacheStore(); + + store.set('a', createCachedValue(1, ['tag-a'])); + store.delete('a'); + + expect(store.get('a')).toBeUndefined(); + expect(store.has('a')).toBe(false); + }); + + it('clears all values', () => { + const store: InMemoryCacheStore = new InMemoryCacheStore(); + + store.set('a', createCachedValue(1, ['tag-a'])); + store.set('b', createCachedValue(2, ['tag-b'])); + + store.clear(); + + expect(store.get('a')).toBeUndefined(); + expect(store.get('b')).toBeUndefined(); + expect(store.has('a')).toBe(false); + expect(store.has('b')).toBe(false); + }); + + it('invalidates only entries with matching tags', () => { + const store: InMemoryCacheStore = new InMemoryCacheStore(); + + store.set('a', createCachedValue(1, ['tag-a', 'tag-common'])); + store.set('b', createCachedValue(2, ['tag-b'])); + store.set('c', createCachedValue(3, ['tag-common'])); + + store.invalidateTags(['tag-common']); + + expect(store.get('a')).toBeUndefined(); + expect(store.get('b')?.value).toBe(2); + expect(store.get('c')).toBeUndefined(); + }); + + it('invalidates entries matching any provided tag', () => { + const store: InMemoryCacheStore = new InMemoryCacheStore(); + + store.set('a', createCachedValue(1, ['tag-a'])); + store.set('b', createCachedValue(2, ['tag-b'])); + store.set('c', createCachedValue(3, ['tag-c'])); + + store.invalidateTags(['tag-b', 'tag-c']); + + expect(store.get('a')?.value).toBe(1); + expect(store.get('b')).toBeUndefined(); + expect(store.get('c')).toBeUndefined(); + }); + + it('does nothing when invalidateTags receives no matching tags', () => { + const store: InMemoryCacheStore = new InMemoryCacheStore(); + + store.set('a', createCachedValue(1, ['tag-a'])); + + store.invalidateTags(['tag-x']); + + expect(store.get('a')?.value).toBe(1); + }); + + it('can handle invalidating an empty cache', () => { + const store: InMemoryCacheStore = new InMemoryCacheStore(); + + expect(() => store.invalidateTags(['tag-a'])).not.toThrow(); + }); + + it('keeps stored object references intact', () => { + type Value = { name: string, nested: { id: number } }; + const store: InMemoryCacheStore = new InMemoryCacheStore(); + const value: Value = { name: 'x', nested: { id: 1 } }; + + store.set('a', createCachedValue(value, ['tag-a'])); + + expect(store.get('a')?.value).toBe(value); + }); +}); \ 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 new file mode 100644 index 0000000..20a3b13 --- /dev/null +++ b/src/caching/store/in-memory.cache-store.ts @@ -0,0 +1,272 @@ +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. + */ +interface CacheEntry { + /** The node inside the order list (holds the key). */ + orderNode: LinkedListNode, + /** The actual cached value. */ + value: CachedValue, + /** Estimated size in bytes (for byte‑based eviction). */ + byteSize: number, + /** Frequency counter (used only by LFU). */ + frequency: number +} + +/** + * 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 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 + private readonly frequencyMap: Map> = new Map(); + private minFrequency: number = 0; + + // eslint-disable-next-line jsdoc/require-jsdoc + readonly config: CacheStoreConfig; + + constructor(configInput?: Partial) { + this.config = { + maxEntries: 500, + maxBytes: Bytes.KB * 750, + removeOnOverflow: 'leastRecentlyUsed', + ...configInput + }; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + get(key: K): CachedValue | undefined { + const entry: CacheEntry | undefined = this.entries.get(key); + if (!entry) { + return undefined; + } + + this.recordAccess(entry); + return entry.value; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + set(key: K, value: CachedValue): void { + const existing: CacheEntry | undefined = this.entries.get(key); + + if (existing) { + this.currentBytes = NumberUtilities.subtract(this.currentBytes, existing.byteSize).toNumber(); + existing.value = value; + existing.byteSize = this.estimateBytes(key, value); + this.currentBytes = NumberUtilities.add(this.currentBytes, existing.byteSize).toNumber(); + this.recordAccess(existing); + return; + } + + const byteSize: number = this.estimateBytes(key, value); + + // Evict until there is enough space + while ( + this.size() >= this.config.maxEntries + || NumberUtilities.add(this.currentBytes, byteSize).comparedTo(this.config.maxBytes) === 1 + ) { + if (this.entries.size <= 0) { + // a single entry exceeds the cache, skip caching for it + return; + } + this.evictOne(); + } + + const orderNode: LinkedListNode = this.orderList.addLast(key); + + const entry: CacheEntry = { + orderNode, + value, + byteSize, + frequency: 1 + }; + this.entries.set(key, entry); + this.currentBytes = NumberUtilities.add(this.currentBytes, byteSize).toNumber(); + + // LFU initialization + if (this.config.removeOnOverflow === 'leastFrequentlyUsed') { + this.increaseFrequency(entry); + } + } + + // eslint-disable-next-line jsdoc/require-jsdoc + delete(key: K): void { + const entry: CacheEntry | undefined = this.entries.get(key); + if (!entry) { + return; + } + + // 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.entries.has(key); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + clear(): void { + this.entries.clear(); + this.orderList.clear(); + this.frequencyMap.clear(); + this.currentBytes = 0; + this.minFrequency = 0; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + size(): number { + return this.entries.size; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + invalidateTags(tags: string[]): void { + // 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 evictOne(): void { + switch (this.config.removeOnOverflow) { + 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': { + // Evict the tail (most recently used) + if (this.orderList.tail) { + this.delete(this.orderList.tail.value); + } + break; + } + case 'leastFrequentlyUsed': { + // 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 + const keyToEvict: K = minSet.values().next().value!; + this.delete(keyToEvict); + return; + } + if (this.orderList.head) { + this.delete(this.orderList.head.value); + } + break; + } + } + } + + private recordAccess(entry: CacheEntry): void { + switch (this.config.removeOnOverflow) { + case 'leastRecentlyUsed': + case 'mostRecentlyUsed': { + // Move to tail (mark as most recent) + this.orderList.moveToTail(entry.orderNode); + break; + } + case 'firstInFirstOut': { + // Do not change order – keep insertion order intact + break; + } + case 'leastFrequentlyUsed': { + // Increase frequency + this.increaseFrequency(entry); + break; + } + } + } + + private increaseFrequency(entry: CacheEntry): void { + const oldFrequency: number = entry.frequency; + const newFrequency: number = oldFrequency + 1; + entry.frequency = newFrequency; + + // remove from old frequency set + const oldSet: Set | undefined = this.frequencyMap.get(oldFrequency); + if (oldSet) { + oldSet.delete(entry.orderNode.value); + if (oldSet.size === 0) { + this.frequencyMap.delete(oldFrequency); + if (oldFrequency === this.minFrequency) { + // find the next min frequency + this.minFrequency = Math.min(...this.frequencyMap.keys()); + if (!Number.isFinite(this.minFrequency)) { + this.minFrequency = 0; + } + } + } + } + + // add to new frequency set + if (!this.frequencyMap.has(newFrequency)) { + this.frequencyMap.set(newFrequency, new Set()); + } + // eslint-disable-next-line typescript/no-non-null-assertion + this.frequencyMap.get(newFrequency)!.add(entry.orderNode.value); + if (newFrequency < this.minFrequency || this.minFrequency === 0) { + this.minFrequency = newFrequency; + } + } + + 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(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; + } + } + } + } + + private estimateBytes(key: K, value: CachedValue): number { + try { + return Buffer.byteLength(JsonUtilities.stringify({ key, value }), 'utf8'); + } + catch { + return 1024; + } + } +} \ No newline at end of file diff --git a/src/change-sets/change-set-repository.ts b/src/change-sets/change-set-repository.ts index a9b8790..9a63951 100644 --- a/src/change-sets/change-set-repository.ts +++ b/src/change-sets/change-set-repository.ts @@ -1,5 +1,4 @@ -import { setTimeout } from 'node:timers/promises'; -import { isDeepStrictEqual } from 'util'; +import { isDeepStrictEqual } from 'node:util'; import { Repository as TORepository } from 'typeorm'; @@ -7,7 +6,13 @@ 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 { DataSourceInterface } from '../data-source/data-sources/data-source.interface'; +import { BeforeReturnHook } from '../data-source/hooks/before-return'; +import { BeforeSaveHook } from '../data-source/hooks/before-save'; 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 +25,16 @@ 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 { JsonUtilities } from '../utilities/json.utilities'; import { MetadataUtilities } from '../utilities/metadata.utilities'; +import { nowInNs } from '../utilities/now-in-ns.function'; 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,22 +62,29 @@ 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; - constructor(entityClass: Newable, repo: TORepository | Repository, logger: LoggerInterface) { - super(entityClass, repo, logger); + constructor( + entityClass: Newable, + repo: TORepository | Repository, + logger: LoggerInterface, + dataSource: DataSourceInterface, + beforeSave: BeforeSaveHook, + beforeReturn: BeforeReturnHook + ) { + super(entityClass, repo, logger, dataSource, beforeSave, beforeReturn); 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); } } } @@ -191,7 +205,7 @@ export class ChangeSetRepository< * Rolls back all changes on the given entity that have happened since the given change set. * This DOES NOT preserve any changes that happened after the change set. * The given change set and any change sets after that will be deleted in the end. - * Calls rollbackByDate on the change set date internally. + * Calls rollbackToTimestamp on the change set timestamp internally. * @param entity - The entity to rollback. * @param changeSetId - The id of the changeSet to rollback to. * @param createChangeSet - Whether or not a change set should be created. @@ -208,14 +222,14 @@ export class ChangeSetRepository< options?: BaseRepositoryOptions ): Promise { const changeSet: ChangeSet = await this.changeSetRepository.findById(changeSetId, options); - return this.rollbackToDate(entity, changeSet.createdAt, createChangeSet, preserveCreateChangeSet, options); + return this.rollbackToTimestamp(entity, changeSet.createdAt, createChangeSet, preserveCreateChangeSet, options); } /** * Rolls back all changes on the entity with the given id that have happened since the given change set. * This DOES NOT preserve any changes that happened after the change set. * The given change set and any change sets after that will be deleted in the end. - * Calls rollbackByDate on the change set date internally. + * Calls rollbackToTimestampById on the change set timestamp internally. * @param id - The id of the entity to rollback. * @param changeSetId - The id of the changeSet to rollback to. * @param createChangeSet - Whether or not a change set should be created. @@ -237,30 +251,30 @@ export class ChangeSetRepository< 'Could not rollback to the given change set: The changeSet doesn\'t belong to the entity with the given id.' ); } - return this.rollbackToDateById(id, changeSet.createdAt, createChangeSet, preserveCreateChangeSet, options); + return this.rollbackToTimestampById(id, changeSet.createdAt, createChangeSet, preserveCreateChangeSet, options); } /** - * Rolls back all changes on the given entity that have happened since the given date. - * This DOES NOT preserve any changes that happened after the date. - * Any change sets after the given date will be deleted in the end. + * Rolls back all changes on the given entity that have happened since the given timestamp. + * This DOES NOT preserve any changes that happened after the timestamp. + * Any change sets after the given timestamp will be deleted in the end. * @param entity - The entity to rollback. - * @param date - The date to which the rollback should happen. + * @param timestampInNs - The timestamp to which the rollback should happen with nanosecond precision. * @param createChangeSet - Whether or not a change set should be created. * @param preserveCreateChangeSet - Whether or not create change sets should be preserved. * In that case the entity gets reset to the state after the create change set. Also, the create change set isn't deleted. * @param options - Additional options, eg. Transaction. * @returns The updated entity. */ - async rollbackToDate( + async rollbackToTimestamp( entity: T, - date: Date, + timestampInNs: bigint, createChangeSet: boolean = true, preserveCreateChangeSet: boolean = true, options?: BaseRepositoryOptions ): Promise { const changeSets: ChangeSet[] = await this.changeSetRepository.findAll({ - where: { changeSetEntityId: entity.id, createdAt: { after: date } }, + where: { changeSetEntityId: entity.id, createdAt: { greaterThan: timestampInNs } }, relations: ['changes'], order: { createdAt: 'ASC' } }); @@ -282,33 +296,33 @@ export class ChangeSetRepository< } /** - * Rolls back all changes on the entity with the given id that have happened since the given date. - * This DOES NOT preserve any changes that happened after the date. - * Any change sets after the given date will be deleted in the end. + * Rolls back all changes on the entity with the given id that have happened since the given timestamp. + * This DOES NOT preserve any changes that happened after the timestamp. + * Any change sets after the given timestamp will be deleted in the end. * @param id - The id of the entity to rollback. - * @param date - The date to which the rollback should happen. + * @param timestampInNs - The timestamp to which the rollback should happen with nanosecond precision. * @param createChangeSet - Whether or not a change set should be created. * @param preserveCreateChangeSet - Whether or not create change sets should be preserved. * In that case the entity gets reset to the state after the create change set. Also, the create change set isn't deleted. * @param options - Additional options, eg. Transaction. * @returns The updated entity. */ - async rollbackToDateById( + async rollbackToTimestampById( id: T['id'], - date: Date, + timestampInNs: bigint, createChangeSet: boolean = true, preserveCreateChangeSet: boolean = true, options?: BaseRepositoryOptions ): Promise { const entity: T = await this.findById(id, options); - return this.rollbackToDate(entity, date, createChangeSet, preserveCreateChangeSet, options); + return this.rollbackToTimestamp(entity, timestampInNs, createChangeSet, preserveCreateChangeSet, options); } /** - * Rolls back all changes on the entities found with the given where filter to the state of the given date. - * This DOES NOT preserve any changes that happened after the date. - * Any change sets after the given date will be deleted in the end. - * @param date - The date to which the rollback should happen. + * Rolls back all changes on the entities found with the given where filter to the state of the given timestamp. + * This DOES NOT preserve any changes that happened after the timestamp. + * Any change sets after the given timestamp will be deleted in the end. + * @param timestampInNs - The timestamp to which the rollback should happen with nanosecond precision. * @param where - A filter to only rollback some entities. * @param createChangeSet - Whether or not a change set should be created. * @param preserveCreateChangeSet - Whether or not create change sets should be preserved. @@ -316,8 +330,8 @@ export class ChangeSetRepository< * @param options - Additional options, eg. Transaction. * @returns The updated entity. */ - async rollbackAllToDate( - date: Date, + async rollbackAllToTimestamp( + timestampInNs: bigint, where?: Where, createChangeSet: boolean = true, preserveCreateChangeSet: boolean = true, @@ -326,7 +340,7 @@ export class ChangeSetRepository< const entitiesToRollback: T[] = await this.findAll({ where: where, ...options }); await PromiseUtilities.allChunked( entitiesToRollback, - e => this.rollbackToDate(e, date, createChangeSet, preserveCreateChangeSet, options) + e => this.rollbackToTimestamp(e, timestampInNs, createChangeSet, preserveCreateChangeSet, options) ); return entitiesToRollback.length; } @@ -347,17 +361,25 @@ export class ChangeSetRepository< options?: CreateOptions, 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(), + createdAt: nowInNs(), createdBy: await this.getCreatedBy(), - changes: this.getChangesFromData(entityPriorChanges, data, type) + changes }; - if (!force && !changeSetData.changes.length) { - return; - } await this.changeSetRepository.create(changeSetData, options); } @@ -378,13 +400,23 @@ export class ChangeSetRepository< force: boolean = false ): 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: nowInNs(), + createdByUserId: userId, + changes + }; })); if (!force) { changeSetData = changeSetData.filter(d => d.changes.length); @@ -423,12 +455,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 +477,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)); } } @@ -465,7 +497,7 @@ export class ChangeSetRepository< ): boolean { return !( isDeepStrictEqual(previousValue, newValue) - || JSON.stringify(previousValue) === JSON.stringify(newValue) + || JsonUtilities.stringify(previousValue) === JsonUtilities.stringify(newValue) ); } } \ No newline at end of file diff --git a/src/change-sets/models/change-set.model.ts b/src/change-sets/models/change-set.model.ts index c9f05cb..7c36c2f 100644 --- a/src/change-sets/models/change-set.model.ts +++ b/src/change-sets/models/change-set.model.ts @@ -18,10 +18,10 @@ export class ChangeSet extends BaseEntity { @Property.string({ enum: ChangeSetType }) type!: ChangeSetType; /** - * The time at which the change happened. + * The time at which the change happened with nanosecond precision. */ - @Property.date() - createdAt!: Date; + @Property.number({ format: 'bigint' }) + createdAt!: bigint; /** * The id of the user that changed something. */ diff --git a/src/change-sets/models/change.model.ts b/src/change-sets/models/change.model.ts index f7ddcde..210c9b8 100644 --- a/src/change-sets/models/change.model.ts +++ b/src/change-sets/models/change.model.ts @@ -27,7 +27,7 @@ export class Change extends BaseEntity { /** * The change set that this change belongs to. */ - @Property.manyToOne({ target: () => ChangeSet, inverseSide: 'changes', required: false }) + @Property.manyToOne({ target: () => ChangeSet, inverseSide: 'changes' }) changeSet!: ChangeSet; } diff --git a/src/change-sets/soft-delete-repository.ts b/src/change-sets/soft-delete-repository.ts index 8b6ec0e..a372c17 100644 --- a/src/change-sets/soft-delete-repository.ts +++ b/src/change-sets/soft-delete-repository.ts @@ -16,6 +16,9 @@ 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 { BeforeReturnHook } from '../data-source/hooks/before-return'; +import { BeforeSaveHook } from '../data-source/hooks/before-save'; import { Where } from '../data-source/models/where/where-filter.model'; import { NotFoundError } from '../error-handling/errors/not-found.error'; import { LoggerInterface } from '../logging/logger.interface'; @@ -51,10 +54,18 @@ export class SoftDeleteRepository< > extends ChangeSetRepository { - protected override readonly keysToExcludeFromChangeSets: (keyof T)[] = ['changeSets', 'deleted']; - - constructor(entityClass: Newable, repo: TORepository | Repository, logger: LoggerInterface) { - super(entityClass, repo, logger); + protected override readonly keysToExcludeFromChangeSets: Set = new Set(); + + constructor( + entityClass: Newable, + repo: TORepository | Repository, + logger: LoggerInterface, + dataSource: DataSourceInterface, + beforeSave: BeforeSaveHook, + beforeReturn: BeforeReturnHook + ) { + super(entityClass, repo, logger, dataSource, beforeSave, beforeReturn); + this.keysToExcludeFromChangeSets.add('deleted'); } // eslint-disable-next-line jsdoc/require-jsdoc @@ -129,17 +140,17 @@ 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); + const res: T = await this.updateById(id, { deleted: true } as UpdateData, options); await this.createChangeSet(entity, { deleted: true } as UpdateData, ChangeSetType.DELETE, options, true); + 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..528ceda --- /dev/null +++ b/src/context/als.utilities.ts @@ -0,0 +1,84 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +import { HttpRequestContext } from './request/http-request.context'; +import { WebsocketRequestContext } from './request/websocket-request.context'; +import { LogCacheContext } from '../logging/log-context.model'; + +/** + * Encapsulates functionality around async local storage. + */ +export abstract class AlsUtilities { + private static readonly httpRequest: AsyncLocalStorage = new AsyncLocalStorage(); + private static readonly websocketRequest: AsyncLocalStorage = new AsyncLocalStorage(); + private static readonly cacheContext: 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; + } + + /** + * Runs the given function with the cache context saved in async local storage. + * @param context - The cache context. + * @param fn - The function to run. + * @returns The result of the function. + */ + static runWithCacheContext(context: LogCacheContext, fn: () => T): T { + const existing: LogCacheContext[] = this.getCurrentCacheContext() ?? []; + // New array — doesn't mutate the parent scope's array + return this.cacheContext.run([...existing, context], fn); + } + + /** + * Resolves the currently active cache context from the async local storage. + * @returns The currently active cache context. + * @throws When the async local storage store has not been initialized yet. + */ + static getCurrentCacheContext(): LogCacheContext[] | undefined { + return this.cacheContext.getStore(); + } +} \ 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..1122343 --- /dev/null +++ b/src/context/base-context.ts @@ -0,0 +1,12 @@ +/** + * 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(); + + constructor() {} +} \ 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..57aed9b --- /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(); + }, 15000); + + 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..a69d776 --- /dev/null +++ b/src/context/request/http-request.context.ts @@ -0,0 +1,44 @@ + +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'; + +/** + * 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 response: HttpResponse, + 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 { + if (!this.has(token)) { + this.tokenValues.set(token.key, token.fn(this)); + } + 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 new file mode 100644 index 0000000..1528009 --- /dev/null +++ b/src/context/request/request-context-token.model.ts @@ -0,0 +1,110 @@ +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'; +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 { KnownHeader } from '../../http/known-header.enum'; +import { MetadataUtilities } from '../../utilities/metadata.utilities'; +import { UUIDUtilities } from '../../utilities/uuid.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 + ) { + 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 = { + 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 => { + 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..32f46eb --- /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 { + if (!this.has(token)) { + this.tokenValues.set(token.key, token.fn(this)); + } + 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/cascade-delete.test.ts b/src/data-source/cascade-delete.test.ts index d563902..50d9022 100644 --- a/src/data-source/cascade-delete.test.ts +++ b/src/data-source/cascade-delete.test.ts @@ -1,58 +1,33 @@ import { beforeAll, afterAll, describe, it, expect } from '@jest/globals'; -import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql'; import { Repository } from './repository'; -import { BaseEntity } from '../entity/base-entity.model'; -import { PostgresDataSource, PostgresOptions } from './data-sources/postgres-data-source.model'; -import { DataSource } from './decorators/data-source.decorator'; -import { MigrationEntity } from './migration/migration-entity.model'; -import { POSTGRES_TEST_IMAGE } from '../__testing__/constants'; import { Child } from '../__testing__/mocks/entities/child.entity'; import { Company } from '../__testing__/mocks/entities/company.entity'; import { Parent } from '../__testing__/mocks/entities/parent.entity'; import { Profile } from '../__testing__/mocks/entities/profile.entity'; import { Role } from '../__testing__/mocks/entities/role.entity'; import { User, UserCreateData, mockCreateUserData } from '../__testing__/mocks/entities/user.entity'; +import { createTestDataSource, defaultTestServerEntities } from '../__testing__/test-server/create-test-data-source.function'; +import { StartedTestServer, startTestServer } from '../__testing__/test-server/start-test-server.function'; +import { repositoryTokenFor } from '../di/decorators/inject-repository.decorator'; import { inject } from '../di/inject.function'; -import { Newable } from '../types/newable.type'; - -@DataSource() -class TestDataSource extends PostgresDataSource { - options: PostgresOptions = { - host: 'localhost', - username: 'postgres', - password: 'password', - database: 'db', - synchronize: true - }; - entities: Newable[] = [MigrationEntity, Parent, Child, User, Company, Profile, Role]; -} describe('cascade delete', () => { - let container: StartedPostgreSqlContainer; - let ds: TestDataSource; + let server: StartedTestServer; beforeAll(async () => { - container = await new PostgreSqlContainer(POSTGRES_TEST_IMAGE) - .withDatabase('db') - .withUsername('postgres') - .withPassword('password') - .start(); - ds = inject(TestDataSource); - ds.options = { - ...ds.options, - port: container.getMappedPort(5432) - }; - await ds.init(); - }, 20000); + server = await startTestServer({ + dataSources: [createTestDataSource({ entities: [...defaultTestServerEntities, Parent, Child, User, Company, Profile, Role] })] + }); + }, 15000); afterAll(async () => { - await container.stop(); + await server?.shutdown(); }); it('deletes children when parent removed', async () => { - const parentRepo: Repository = ds.getRepository(Parent); - const childRepo: Repository = ds.getRepository(Child); + const parentRepo: Repository = inject(repositoryTokenFor(Parent)); + const childRepo: Repository = inject(repositoryTokenFor(Child)); const parent: Parent = await parentRepo.create({ name: 'p1' }); await childRepo.create({ name: 'c1', parent: { id: parent.id } }); await childRepo.create({ name: 'c2', parent }); @@ -69,8 +44,8 @@ describe('cascade delete', () => { }); it('deletes profile when user removed', async () => { - const userRepo: Repository = ds.getRepository(User); - const profileRepo: Repository = ds.getRepository(Profile); + const userRepo: Repository = inject(repositoryTokenFor(User)); + const profileRepo: Repository = inject(repositoryTokenFor(Profile)); const user: User = await userRepo.create(mockCreateUserData()); const profile: Profile = await profileRepo.create({ bio: 'Bio', user }); @@ -87,8 +62,8 @@ describe('cascade delete', () => { }); it('removes user from roles when user removed', async () => { - const userRepo: Repository = ds.getRepository(User); - const roleRepo: Repository = ds.getRepository(Role); + const userRepo: Repository = inject(repositoryTokenFor(User)); + const roleRepo: Repository = inject(repositoryTokenFor(Role)); const user1: User = await userRepo.create(mockCreateUserData()); const user2: User = await userRepo.create(mockCreateUserData()); 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..7cbd29b 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'; @@ -33,6 +32,7 @@ import { Version } from '../../types/version.type'; import { compareVersion } from '../../utilities/compare-versions.function'; import { MetadataUtilities } from '../../utilities/metadata.utilities'; import { ObjectUtilities } from '../../utilities/object.utilities'; +import { getDefaultBeforeReturnHook, getDefaultBeforeSaveHook } from '../hooks/hooks.default'; import { MigrationEntity } from '../migration/migration-entity.model'; import { Migration } from '../migration/migration.model'; import { ColumnType } from '../models/column-type.model'; @@ -221,7 +221,11 @@ export abstract class PostgresDataSource implements DataSourceInterface { } const props: Record = MetadataUtilities.getModelProperties(cls); - const numberOfPrimaryKeys: number = ObjectUtilities.values(props).filter(d => (d as StringPropertyMetadata).primary).length; + const numberOfPrimaryKeys: number = ObjectUtilities + .values(props) + // eslint-disable-next-line typescript/no-explicit-any + .filter(d => (d as StringPropertyMetadata).primary) + .length; if (numberOfPrimaryKeys === 0) { throw new Error(`no primary key specified for entity "${cls.name}".`); } @@ -358,13 +362,20 @@ export abstract class PostgresDataSource implements DataSourceInterface { nullable, generated: metadata.primary ? 'increment' : undefined, ...metadata, - type: this.columnTypeMapping[metadata.type], + type: metadata.format ?? this.columnTypeMapping[metadata.type], default: undefined, transformer: { // eslint-disable-next-line unicorn/no-null - to: (v: number | null) => v != null ? String(v) : null, - // eslint-disable-next-line unicorn/no-null - from: (v: string | null) => v != null ? Number(v) : undefined + to: (v: number | bigint | null) => v != null ? String(v) : null, + from: (v: string | null) => { + if (v == undefined) { + return v; + } + if (metadata.format === 'bigint') { + return BigInt(v); + } + return Number(v); + } } }; } @@ -397,17 +408,30 @@ export abstract class PostgresDataSource implements DataSourceInterface { return new SoftDeleteRepository( cls, repo as unknown as TORepository, - this.logger + this.logger, + this, + getDefaultBeforeSaveHook(), + getDefaultBeforeReturnHook() ) as unknown as Repository; } if (isChangeSetEntityNewable(cls)) { return new ChangeSetRepository( cls, repo as unknown as TORepository, - this.logger + this.logger, + this, + getDefaultBeforeSaveHook(), + getDefaultBeforeReturnHook() ) as unknown as Repository; } - return new Repository(cls, repo, this.logger); + return new Repository( + cls, + repo, + this.logger, + this, + getDefaultBeforeSaveHook(), + getDefaultBeforeReturnHook() + ); } // eslint-disable-next-line jsdoc/require-jsdoc diff --git a/src/data-source/exclude-property.test.ts b/src/data-source/exclude-property.test.ts new file mode 100644 index 0000000..efe022b --- /dev/null +++ b/src/data-source/exclude-property.test.ts @@ -0,0 +1,383 @@ +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'; +import { JsonUtilities } from '../utilities/json.utilities'; + +// ─── 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('JsonUtilities serialization', () => { + it('excluded properties are absent from JsonUtilities.stringify output', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + + const json: unknown = JsonUtilities.parse(JsonUtilities.stringify(user)); + expect(json).not.toHaveProperty('passwordHash'); + expect(json).not.toHaveProperty('secret'); + }); + + it('non-excluded properties are present in JsonUtilities.stringify output', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + + const json: unknown = JsonUtilities.parse(JsonUtilities.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 = JsonUtilities.parse(JsonUtilities.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 = JsonUtilities.parse(JsonUtilities.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 JsonUtilities.stringify output', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + restoreExcludeProperties(user, User); + + const json: User = JsonUtilities.parse(JsonUtilities.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 = JsonUtilities.parse(JsonUtilities.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(JsonUtilities.parse(JsonUtilities.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/hooks/before-return.ts b/src/data-source/hooks/before-return.ts new file mode 100644 index 0000000..b3eca00 --- /dev/null +++ b/src/data-source/hooks/before-return.ts @@ -0,0 +1,7 @@ +import { BaseEntity } from '../../entity/base-entity.model'; +import { Newable } from '../../types/newable.type'; + +/** + * A hook that runs before an entity is returned. + */ +export type BeforeReturnHook = (res: T, entity: Newable) => void | Promise; \ No newline at end of file diff --git a/src/data-source/hooks/before-save.ts b/src/data-source/hooks/before-save.ts new file mode 100644 index 0000000..259f876 --- /dev/null +++ b/src/data-source/hooks/before-save.ts @@ -0,0 +1,16 @@ +import { BaseEntity } from '../../entity/base-entity.model'; +import { DeepPartial } from '../../types/deep-partial.type'; +import { Newable } from '../../types/newable.type'; + +/** + * A hook that runs before an entity is saved. + */ +export type BeforeSaveHook< + T extends BaseEntity, + CreateData extends DeepPartial = DeepPartial, + UpdateData extends DeepPartial = DeepPartial +> = ( + data: T | CreateData | UpdateData, + setDefault: boolean, + entity: Newable +) => void | Promise; \ No newline at end of file diff --git a/src/data-source/hooks/hooks.default.ts b/src/data-source/hooks/hooks.default.ts new file mode 100644 index 0000000..c5343be --- /dev/null +++ b/src/data-source/hooks/hooks.default.ts @@ -0,0 +1,43 @@ +/* eslint-disable jsdoc/require-returns */ +import { BeforeReturnHook } from './before-return'; +import { BeforeSaveHook } from './before-save'; +import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; +import { inject } from '../../di/inject.function'; +import { BaseEntity } from '../../entity/base-entity.model'; +import { decryptEncryptionProperties } from '../../global/model-registry/decrypt-encryption-properties.function'; +import { encryptEncryptionProperties } from '../../global/model-registry/encrypt-encryption-properties.function'; +import { hashHashProperties } from '../../global/model-registry/hash-hash-properties.function'; +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'; +import { DeepPartial } from '../../types/deep-partial.type'; + +/** + * Gets the default before return hook. + */ +export function getDefaultBeforeReturnHook(): BeforeReturnHook { + const res: BeforeReturnHook = async (data, cls) => { + await decryptEncryptionProperties(data, cls, inject(ZIBRI_DI_TOKENS.ENCRYPTION_SERVICE)); + await removeExcludeProperties(data, cls); + }; + return res; +} + +/** + * Gets the default before save hook. + */ +export function getDefaultBeforeSaveHook< + T extends BaseEntity, + CreateData extends DeepPartial = DeepPartial, + UpdateData extends DeepPartial = DeepPartial +>(): BeforeSaveHook { + const res: BeforeSaveHook = async (data, setDefault, cls) => { + restoreExcludeProperties(data, cls); + if (setDefault) { + await setDefaultValues(data, cls); + } + await encryptEncryptionProperties(data, cls, inject(ZIBRI_DI_TOKENS.ENCRYPTION_SERVICE)); + await hashHashProperties(data, cls, inject(ZIBRI_DI_TOKENS.HASH_SERVICE)); + }; + return res; +} \ No newline at end of file diff --git a/src/data-source/migration/migration.test.ts b/src/data-source/migration/migration.test.ts index 5585a2f..7d6d063 100644 --- a/src/data-source/migration/migration.test.ts +++ b/src/data-source/migration/migration.test.ts @@ -1,3 +1,5 @@ +import { randomBytes } from 'node:crypto'; + import { beforeAll, afterAll, describe, it, expect } from '@jest/globals'; import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql'; import { Table, TableColumn } from 'typeorm'; @@ -5,9 +7,13 @@ import { Table, TableColumn } from 'typeorm'; import { MigrationEntity } from './migration-entity.model'; import { Migration } from './migration.model'; import { POSTGRES_TEST_IMAGE } from '../../__testing__/constants'; +import { defaultTestServerEntities } from '../../__testing__/test-server/create-test-data-source.function'; +import { AesGcmEncryptionStrategy } from '../../auth/encryption/strategies/aes-gcm.encryption-strategy'; import { InjectRepository } from '../../di/decorators/inject-repository.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 { BaseEntity } from '../../entity/base-entity.model'; import { Entity } from '../../entity/decorators/entity.decorator'; import { Property } from '../../entity/decorators/property.decorator'; @@ -34,7 +40,7 @@ class LegacyDbDataSource extends PostgresDataSource { database: 'db', synchronize: true }; - entities: Newable[] = [MigrationEntity, LegacyItem]; + entities: Newable[] = [...defaultTestServerEntities, LegacyItem]; } @Entity({ tableName: 'item' }) @@ -55,7 +61,7 @@ class DbDataSource extends PostgresDataSource { database: 'db', synchronize: true }; - entities: Newable[] = [MigrationEntity, Item]; + entities: Newable[] = [...defaultTestServerEntities, Item]; migrations: Newable[] = [AddTestValueMigration]; } @@ -94,6 +100,13 @@ describe('AddTestValueMigration', () => { .start(); GlobalRegistry['appData'].version = '0.0.1'; + register({ + token: ZIBRI_DI_TOKENS.ENCRYPTION_MASTER_OPTIONS, + useValue: { + currentMasterStrategy: new AesGcmEncryptionStrategy(), + currentMasterKey: { id: 'mk1', value: randomBytes(32) } + } + }); const legacyDataSource: LegacyDbDataSource = inject(LegacyDbDataSource); legacyDataSource.options = { diff --git a/src/data-source/models/where/number-where-filter.model.ts b/src/data-source/models/where/number-where-filter.model.ts index d1f89c8..0c36d84 100644 --- a/src/data-source/models/where/number-where-filter.model.ts +++ b/src/data-source/models/where/number-where-filter.model.ts @@ -3,33 +3,33 @@ import { BaseWhereFilter } from './base-where-filter.model'; /** * A filter for a number where property. */ -export type NumberWhereFilter = BaseWhereFilter | { +export type NumberWhereFilter = BaseWhereFilter | { /** * The property needs to be not this value. */ - not?: number, + not?: T, /** * The property needs to be one of the values in the array. */ - oneOf?: number[], + oneOf?: T[], /** * The property needs to be NOT one of the values in the array. */ - notOneOf?: number[], + notOneOf?: T[], /** * The property needs to be greater than the provided value. */ - greaterThan?: number, + greaterThan?: T, /** * The property needs to be greater than or equal to the provided value. */ - greaterThanEquals?: number, + greaterThanEquals?: T, /** * The property needs to be lesser than the provided value. */ - lesserThan?: number, + lesserThan?: T, /** * The property needs to be lesser than or equal to the provided value. */ - lesserThanEquals?: number + lesserThanEquals?: T }; \ No newline at end of file diff --git a/src/data-source/models/where/where-filter-to-find-options-where-function.test.ts b/src/data-source/models/where/where-filter-to-find-options-where-function.test.ts index cf3a845..ede10dd 100644 --- a/src/data-source/models/where/where-filter-to-find-options-where-function.test.ts +++ b/src/data-source/models/where/where-filter-to-find-options-where-function.test.ts @@ -6,6 +6,7 @@ import { whereFilterToFindOptionsWhere } from './where-filter-to-find-options-wh import { Where } from './where-filter.model'; import { Address } from '../../../__testing__/mocks/entities/address.model'; import { User } from '../../../__testing__/mocks/entities/user.entity'; +import { JsonUtilities } from '../../../utilities/json.utilities'; describe('whereFilterToFindOptionsWhere - primitive filters', () => { it('string equality', () => { @@ -88,7 +89,7 @@ describe('whereFilterToFindOptionsWhere - object filters', () => { { json: { street: Equal('Main St') } } ) }; - expect(JSON.stringify(result)).toEqual(JSON.stringify(expectedResult)); + expect(JsonUtilities.stringify(result)).toEqual(JsonUtilities.stringify(expectedResult)); }); it('nested where on relation fields yields nested filter', () => { const filter: Where = { company: { where: { id: '42' } } }; 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..2dc72a7 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); } /** @@ -132,7 +136,7 @@ type ArrayWhereFilterKeys = ( */ type WhereFilterKeys = ArrayWhereFilterKeys | keyof ExcludeStrict> - | keyof ExcludeStrict> + | keyof ExcludeStrict, BaseWhereFilter> | ObjectWhereFilterKeys | keyof ExcludeStrict>; @@ -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/models/where/where-filter.model.ts b/src/data-source/models/where/where-filter.model.ts index 9b3534d..2871dea 100644 --- a/src/data-source/models/where/where-filter.model.ts +++ b/src/data-source/models/where/where-filter.model.ts @@ -20,17 +20,19 @@ export type WhereFilter = { /** * The definition for a single where filter property. */ -export type WhereFilterProperty = T extends string - ? StringWhereFilter - : T extends number - ? NumberWhereFilter - : T extends boolean - ? BooleanWhereFilter +export type WhereFilterProperty = T extends bigint + ? NumberWhereFilter + : T extends string + ? StringWhereFilter + : T extends number + ? NumberWhereFilter + : T extends boolean + ? BooleanWhereFilter // eslint-disable-next-line typescript/no-explicit-any - : T extends any[] - ? ArrayWhereFilter - : T extends Date - ? DateWhereFilter - : T extends object - ? ObjectWhereFilter - : never; \ No newline at end of file + : T extends any[] + ? ArrayWhereFilter + : T extends Date + ? DateWhereFilter + : T extends object + ? ObjectWhereFilter + : never; \ No newline at end of file diff --git a/src/data-source/repository.test.ts b/src/data-source/repository.test.ts index 35f8eda..23c4cdd 100644 --- a/src/data-source/repository.test.ts +++ b/src/data-source/repository.test.ts @@ -1,16 +1,13 @@ import { beforeAll, afterAll, describe, it, expect } from '@jest/globals'; -import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql'; -import { POSTGRES_TEST_IMAGE } from '../__testing__/constants'; -import { PostgresDataSource, PostgresOptions } from './data-sources/postgres-data-source.model'; -import { DataSource } from './decorators/data-source.decorator'; -import { MigrationEntity } from './migration/migration-entity.model'; import { Repository } from './repository'; +import { createTestDataSource, defaultTestServerEntities } from '../__testing__/test-server/create-test-data-source.function'; +import { StartedTestServer, startTestServer } from '../__testing__/test-server/start-test-server.function'; +import { repositoryTokenFor } from '../di/decorators/inject-repository.decorator'; import { inject } from '../di/inject.function'; import { BaseEntity } from '../entity/base-entity.model'; import { Entity } from '../entity/decorators/entity.decorator'; import { Property } from '../entity/decorators/property.decorator'; -import { Newable } from '../types/newable.type'; import { OmitStrict } from '../types/omit-strict.type'; @Entity() @@ -34,42 +31,21 @@ class VisitStats extends BaseEntity { date!: Date; } -@DataSource() -class TestDataSource extends PostgresDataSource { - options: PostgresOptions = { - host: 'localhost', - username: 'postgres', - password: 'password', - database: 'db', - synchronize: true - }; - entities: Newable[] = [MigrationEntity, VisitStats]; -} - describe('repository', () => { - let container: StartedPostgreSqlContainer; - let ds: TestDataSource; + let server: StartedTestServer; beforeAll(async () => { - container = await new PostgreSqlContainer(POSTGRES_TEST_IMAGE) - .withDatabase('db') - .withUsername('postgres') - .withPassword('password') - .start(); - ds = inject(TestDataSource); - ds.options = { - ...ds.options, - port: container.getMappedPort(5432) - }; - await ds.init(); - }, 20000); + server = await startTestServer({ + dataSources: [createTestDataSource({ entities: [...defaultTestServerEntities, VisitStats] })] + }); + }, 15000); afterAll(async () => { - await container.stop(); + await server?.shutdown(); }); it('create', async () => { - const repo: Repository = ds.getRepository(VisitStats); + const repo: Repository = inject(repositoryTokenFor(VisitStats)); const visitStats: OmitStrict = { count: 1, countFirstVisit: 0, diff --git a/src/data-source/repository.ts b/src/data-source/repository.ts index 73550d1..227f808 100644 --- a/src/data-source/repository.ts +++ b/src/data-source/repository.ts @@ -1,18 +1,13 @@ -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 { 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 { DataSourceInterface } from './data-sources/data-source.interface'; +import { BeforeReturnHook } from './hooks/before-return'; +import { BeforeSaveHook } from './hooks/before-save'; 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 +18,12 @@ 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'; /** * A repository that handles data source related things for its entity. @@ -35,12 +35,24 @@ 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, + private readonly beforeSave: BeforeSaveHook, + private readonly beforeReturn: BeforeReturnHook ) { this.typeOrmRepository = repo instanceof Repository ? repo.typeOrmRepository : repo; + ModelRegistry.get(this.entityClass); } private getManager(transaction: Transaction | undefined): EntityManager { @@ -54,94 +66,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 +77,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, this.entityClass); + const manager: EntityManager = this.getManager(options?.transaction); try { - return await manager.save(this.entityClass, data); + const res: T = await manager.save(this.entityClass, data as ToDeepPartial); + await this.beforeReturn(res, this.entityClass); + return res; } catch (error) { if (error instanceof TOQueryFailedError) { @@ -173,14 +100,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, this.entityClass); + 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 +119,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 as ToDeepPartial[]); + await Promise.all(res.map(r => this.beforeReturn(r, this.entityClass))); + return res; } catch (error) { if (error instanceof TOQueryFailedError) { @@ -242,7 +174,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 this.beforeReturn(res, this.entityClass); + return res; } /** @@ -254,10 +190,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 => this.beforeReturn(r, this.entityClass))); + return res; } catch (error) { if (error instanceof TOQueryFailedError) { @@ -311,10 +249,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, this.entityClass); try { - return await manager.save(this.entityClass, dataWithId); + const res: T = await manager.save(this.entityClass, data as ToDeepPartial); + await this.beforeReturn(res, this.entityClass); + return res; } catch (error) { if (error instanceof TOQueryFailedError) { @@ -340,12 +281,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, this.entityClass); 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 as ToDeepPartial[]); + await Promise.all(res.map(r => this.beforeReturn(r, this.entityClass))); + return res; } catch (error) { if (error instanceof TOQueryFailedError) { @@ -359,12 +303,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); + await this.beforeSave(entityToDelete, false, 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 this.beforeReturn(res, this.entityClass); + return res; } catch (error) { if (error instanceof TOQueryFailedError) { @@ -378,16 +326,20 @@ 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 }); + await Promise.all(toDelete.map(r => this.beforeSave(r, false, 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 => this.beforeReturn(r, this.entityClass))); + return res; } catch (error) { if (error instanceof TOQueryFailedError) { diff --git a/src/di/decorators/inject-repository.decorator.ts b/src/di/decorators/inject-repository.decorator.ts index dd692b0..fd6defa 100644 --- a/src/di/decorators/inject-repository.decorator.ts +++ b/src/di/decorators/inject-repository.decorator.ts @@ -15,7 +15,7 @@ const allRepositoryTokens: Record>> = {}; export function repositoryTokenFor>(entity: T): DiToken>> { const key: string = `Repository<${entity.name}>`; allRepositoryTokens[key] ??= new InjectionToken(key); - return allRepositoryTokens[key] as DiToken>>; + return allRepositoryTokens[key] as unknown as DiToken>>; } /** diff --git a/src/di/default/zibri-di-providers.default.ts b/src/di/default/zibri-di-providers.default.ts index 72c1ea5..dfe9cad 100644 --- a/src/di/default/zibri-di-providers.default.ts +++ b/src/di/default/zibri-di-providers.default.ts @@ -9,12 +9,22 @@ import { ZIBRI_DI_TOKENS } from './zibri-di-tokens.default'; import { AssetService } from '../../assets/asset.service'; import { TwoFactorService } from '../../auth/2fa/two-factor.service'; import { AuthService } from '../../auth/auth.service'; +import { EncryptionService } from '../../auth/encryption/encryption.service'; +import { AesGcmEncryptionStrategy } from '../../auth/encryption/strategies/aes-gcm.encryption-strategy'; +import { HashService } from '../../auth/hash/hash.service'; +import { BcryptHashStrategy } from '../../auth/hash/strategies/bcrypt.hash-strategy'; import { UserService } from '../../auth/user/user.service'; import { BackupService } from '../../backup/backup.service'; +import { CacheService } from '../../caching/cache.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'; 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'; @@ -23,8 +33,8 @@ 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 { getCurrentRequest } from '../../routing/request.context'; import { Router } from '../../routing/router'; import { FsUtilities } from '../../utilities/fs.utilities'; import { Ms } from '../../utilities/ms'; @@ -71,12 +81,12 @@ 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 }, 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 }, @@ -97,9 +107,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 }, - CURRENT_REQUEST: { useFactory: () => getCurrentRequest() }, + PASSWORD_RESET_TOKEN_EXPIRES_IN_MS: { useFactory: () => 300000 }, + CONFIRM_PASSWORD_RESET_URL: { useFactory: () => undefined }, MULTITHREADING_OPTIONS: { useFactory: () => ({ maxThreads, @@ -111,5 +120,65 @@ 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 }, + 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 }, + HASH_SERVICE: { useClass: HashService }, + HASH_STRATEGIES: { useValue: [BcryptHashStrategy] }, + ENCRYPTION_SERVICE: { useClass: EncryptionService }, + ENCRYPTION_STRATEGIES: { useValue: [AesGcmEncryptionStrategy] }, + ENCRYPTION_MASTER_OPTIONS: { useValue: undefined }, + CACHE_SERVICE: { useClass: CacheService }, + // 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 + }, + CURRENT_CACHE_CONTEXT: { + useFactory: () => AlsUtilities.getCurrentCacheContext(), + 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..f2b255c 100644 --- a/src/di/default/zibri-di-tokens.default.ts +++ b/src/di/default/zibri-di-tokens.default.ts @@ -1,20 +1,30 @@ import { AssetServiceInterface } from '../../assets/asset-service.interface'; import { TwoFactorServiceInterface } from '../../auth/2fa/two-factor-service.interface'; import { AuthServiceInterface } from '../../auth/auth-service.interface'; +import { EncryptionMasterOptions } from '../../auth/encryption/encryption-master-options.model'; +import { EncryptionServiceInterface } from '../../auth/encryption/encryption-service.interface'; +import { EncryptionStrategyInterface } from '../../auth/encryption/strategies/encryption-strategy.interface'; +import { HashServiceInterface } from '../../auth/hash/hash-service.interface'; +import { HashStrategyInterface } from '../../auth/hash/strategies/hash-strategy.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'; +import { CacheServiceInterface } from '../../caching/cache-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'; import { FormatPriceFn } from '../../localization/formatting/format-price-fn.model'; import { LocalizeOptionsInput, LocalizeOptions } from '../../localization/models/localize-options.model'; +import { LogCacheContext } from '../../logging/log-context.model'; import { LogLevel } from '../../logging/log-level.enum'; import { LoggerInterface } from '../../logging/logger.interface'; import { LoggerTransport, BaseLoggerTransportConfig } from '../../logging/transport/logger-transport.model'; @@ -22,8 +32,10 @@ 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 { Newable } from '../../types/newable.type'; import { FsPath } from '../../utilities/fs.utilities'; import { ValidationServiceInterface } from '../../validation/validation-service.interface'; import { WebsocketOptions } from '../../websocket/models/websocket-options.model'; @@ -41,6 +53,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'), @@ -62,10 +75,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'), @@ -76,11 +93,30 @@ 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'), + CORRELATION_ID_HEADER: ziToken('zi.correlation_id_header'), + CSRF_TOKEN_HEADER: ziToken('zi.csrf_token_header'), + COOKIE_SIGN_SECRET: ziToken('zi.cookie_sign_secret'), + HASH_SERVICE: ziToken('zi.hash_service'), + HASH_STRATEGIES: ziToken>>[]>('zi.hash_strategies'), + ENCRYPTION_SERVICE: ziToken('zi.encryption_service'), + ENCRYPTION_STRATEGIES: ziToken< + // eslint-disable-next-line typescript/no-explicit-any + Newable>[] + >('zi.encryption_strategies'), + // eslint-disable-next-line typescript/no-explicit-any + ENCRYPTION_MASTER_OPTIONS: ziToken | undefined>( + 'zi.encryption_master_options' + ), + CACHE_SERVICE: ziToken('zi.cache_service'), + // dynamic/context based tokens + CURRENT_REQUEST_CONTEXT: ziToken('zi.current_request_context'), + DEFAULT_CSP_OPTIONS: ziToken('zi.default_csp_options'), + CURRENT_CACHE_CONTEXT: ziToken('zi.current_cache_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..7bbcb0b 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); @@ -121,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`); @@ -153,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/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/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/entity/decorators/property.decorator.ts b/src/entity/decorators/property.decorator.ts index e5760b3..d68827a 100644 --- a/src/entity/decorators/property.decorator.ts +++ b/src/entity/decorators/property.decorator.ts @@ -1,6 +1,8 @@ +import { BaseDecryptOptions, BaseEncryptOptions } from '../../auth/encryption/strategies/encryption-strategy.interface'; import { warn } from '../../logging/logger.helpers'; import { Newable } from '../../types/newable.type'; import { MetadataUtilities } from '../../utilities/metadata.utilities'; +import { AnyObject } from '../any-object.model'; import type { BaseEntity } from '../base-entity.model'; import { ArrayPropertyMetadata, ArrayPropertyMetadataInput, ArrayPropertyItemMetadataInput, ArrayPropertyItemMetadata } from '../models/array-property-metadata.model'; import type { WithDefaultMetadata } from '../models/base-property-metadata.model'; @@ -20,7 +22,8 @@ import { UnknownPropertyMetadata, UnknownPropertyMetadataInput } from '../models /** * The metadata of a property. */ -export type PropertyMetadata = StringPropertyMetadata +// eslint-disable-next-line typescript/no-explicit-any +export type PropertyMetadata = StringPropertyMetadata | NumberPropertyMetadata | ObjectPropertyMetadata | ArrayPropertyMetadata @@ -41,7 +44,8 @@ export type RelationMetadata = ManyToOnePropertyMetadata | NumberPropertyMetadataInput | ObjectPropertyMetadataInput | ArrayPropertyMetadataInput @@ -70,8 +74,14 @@ export namespace Property { * Defines a string property. * @param data - Additional data to specify the property. */ - export function string(data?: StringPropertyMetadataInput): PropertyDecorator { - const fullMetadata: StringPropertyMetadata = { + export function string< + Data, + TKey, + TEncryptOptions extends BaseEncryptOptions, + TDecryptOptions extends BaseDecryptOptions, + THashOptions extends AnyObject + >(data?: StringPropertyMetadataInput): PropertyDecorator { + const fullMetadata: StringPropertyMetadata = { required: true, primary: false, type: 'string', @@ -83,7 +93,10 @@ export namespace Property { regex: undefined, enum: undefined, default: undefined, - excludeFromChangeSets: false, + excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, + exclude: false, + encryption: false, + hash: false, ...data }; return applyData(fullMetadata, data); @@ -103,8 +116,10 @@ export namespace Property { min: undefined, max: undefined, default: undefined, - excludeFromChangeSets: false, + excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, + exclude: false, enum: undefined, + format: undefined, ...data }; return applyData(fullMetadata, data); @@ -120,7 +135,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 +154,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 +170,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 +196,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 +220,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 +245,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 +262,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 +279,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 +297,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 +315,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 +333,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 +345,17 @@ 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."`); + } + if ( + 'encryption' in data && 'hash' in data + && data.encryption !== undefined && data.encryption !== false + && data.hash !== undefined && data.hash !== false + ) { + throw new Error(`${target.constructor.name}.${key.toString()}: Cannot set the flags "encryption" and "hash" at the same time.`); } const ctor: Newable = target.constructor as Newable; // eslint-disable-next-line unicorn/error-message @@ -338,6 +373,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 +388,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 +406,10 @@ export function createArrayItemPropertyMetadata( regex: undefined, enum: undefined, default: undefined, - excludeFromChangeSets: false, + exclude: false, + encryption: false, + hash: false, + excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, ...data }; } @@ -377,7 +417,8 @@ export function createArrayItemPropertyMetadata( return { required: true, description: undefined, - excludeFromChangeSets: false, + exclude: false, + excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, ...data }; } @@ -385,7 +426,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 +437,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 +449,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 +458,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 +478,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/generation/generate-entity-file.function.ts b/src/entity/generation/generate-entity-file.function.ts index b0deb2d..5b13ad1 100644 --- a/src/entity/generation/generate-entity-file.function.ts +++ b/src/entity/generation/generate-entity-file.function.ts @@ -3,6 +3,7 @@ import { getEntityFileName } from './get-entity-file-name.function'; import { EntityGenerationProvider } from './providers/entity-generation-provider.interface'; import { OpenApiReferenceObject, OpenApiSchemaObject, OpenApiSchemas } from '../../open-api/open-api.model'; import { addImportStatement } from '../../utilities/add-import-statement.function'; +import { JsonUtilities } from '../../utilities/json.utilities'; import { ObjectUtilities } from '../../utilities/object.utilities'; import { toPascalCase } from '../../utilities/to-pascal-case.function'; @@ -112,7 +113,7 @@ function mapSchemaToTsType( switch (schema.type) { case 'string': { if (schema.enum) { - return { type: schema.enum.map(v => JSON.stringify(v).replaceAll('"', '\'')).join(' | '), isRef: false }; + return { type: schema.enum.map(v => JsonUtilities.stringify(v).replaceAll('"', '\'')).join(' | '), isRef: false }; } if (schema.format === 'date-time') { return { type: 'Date', isRef: false }; @@ -173,11 +174,11 @@ function mapSchemaToDecoratorLines( if (isRequired) { return [' @Property.date()']; } - return [` @Property.date({ ${!isRequired ? 'required: false' : ''} })`]; + return [' @Property.date({ required: false })']; } // TODO // if (schema.enum) { - // return { type: schema.enum.map(v => JSON.stringify(v)).join(' | '), isRef: false }; + // return { type: schema.enum.map(v => JsonUtilities.stringify(v)).join(' | '), isRef: false }; // } if (isRequired) { return [' @Property.string()']; diff --git a/src/entity/generation/generate-entity-files.function.ts b/src/entity/generation/generate-entity-files.function.ts index f473b27..96fb3d2 100644 --- a/src/entity/generation/generate-entity-files.function.ts +++ b/src/entity/generation/generate-entity-files.function.ts @@ -1,4 +1,4 @@ -import { register } from 'ts-node'; +import { register as tsNodeRegister } from 'ts-node'; import { FileToGenerate, generateEntityFilesForProvider, GenerateEntityFilesForProviderResult } from './generate-entity-files-for-provider.function'; import { EntityGenerationProvider } from './providers/entity-generation-provider.interface'; @@ -14,7 +14,8 @@ export async function generateEntityFiles(): Promise { if (ext !== '.ts') { return; } - register(); + // Register ts-node so the dynamically required providers file can be loaded when it is authored in TypeScript. + tsNodeRegister(); // eslint-disable-next-line typescript/no-explicit-any, typescript/no-unsafe-assignment, typescript/no-require-imports, typescript/no-var-requires const imported: any = require(providersPath); // eslint-disable-next-line typescript/no-unsafe-member-access diff --git a/src/entity/generation/providers/open-api-file.provider.ts b/src/entity/generation/providers/open-api-file.provider.ts index e58c53d..c193f57 100644 --- a/src/entity/generation/providers/open-api-file.provider.ts +++ b/src/entity/generation/providers/open-api-file.provider.ts @@ -2,6 +2,7 @@ import { EntityGenerationProvider } from './entity-generation-provider.interface import { openApiToV3 } from './open-api-to-v3.function'; import { OpenApiDefinition } from '../../../open-api/open-api.model'; import { FsUtilities, FsPath } from '../../../utilities/fs.utilities'; +import { JsonUtilities } from '../../../utilities/json.utilities'; /** * An entity generation provider using a local open api file. @@ -16,7 +17,7 @@ export class OpenApiFileProvider implements EntityGenerationProvider { // eslint-disable-next-line jsdoc/require-jsdoc async resolveSpec(): Promise { - const spec: unknown = JSON.parse(await FsUtilities.readFile(this.filePath)); + const spec: unknown = JsonUtilities.parse(await FsUtilities.readFile(this.filePath)); return await openApiToV3(spec); } } \ No newline at end of file diff --git a/src/entity/models/array-property-metadata.model.ts b/src/entity/models/array-property-metadata.model.ts index d53e446..5cb00c7 100644 --- a/src/entity/models/array-property-metadata.model.ts +++ b/src/entity/models/array-property-metadata.model.ts @@ -36,8 +36,8 @@ export type ArrayPropertyItemMetadata = ExcludeStrict & { type: 'string' } // eslint-disable-next-line jsdoc/require-jsdoc | NumberPropertyMetadataInput & { type: 'number' } // eslint-disable-next-line jsdoc/require-jsdoc diff --git a/src/entity/models/base-property-metadata.model.ts b/src/entity/models/base-property-metadata.model.ts index f648ecf..198b639 100644 --- a/src/entity/models/base-property-metadata.model.ts +++ b/src/entity/models/base-property-metadata.model.ts @@ -1,24 +1,49 @@ -import { BaseEntity } from '../base-entity.model'; +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 +51,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..b07ba5f 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, NumberUtilities } from '../../utilities/number.utilities'; /** * Possible file size values. @@ -16,18 +16,18 @@ export type FileSize = `${number}b` | `${number}kb` | `${number}mb` | `${number} export function fileSizeToBytes(size: FileSize): BigNumber { if (size.endsWith('gb')) { const [amount] = size.split('gb'); - return BigNumberUtilities.new(Number(amount)).multipliedBy(1073741824); + return NumberUtilities.new(Number(amount)).multipliedBy(1073741824); } if (size.endsWith('mb')) { const [amount] = size.split('mb'); - return BigNumberUtilities.new(Number(amount)).multipliedBy(1048576); + return NumberUtilities.new(Number(amount)).multipliedBy(1048576); } if (size.endsWith('kb')) { const [amount] = size.split('kb'); - return BigNumberUtilities.new(Number(amount)).multipliedBy(1024); + return NumberUtilities.new(Number(amount)).multipliedBy(1024); } const [amount] = size.split('b'); - return BigNumberUtilities.new(Number(amount)); + return NumberUtilities.new(Number(amount)); } /** diff --git a/src/entity/models/number-property-metadata.model.ts b/src/entity/models/number-property-metadata.model.ts index 6d7a883..79279a5 100644 --- a/src/entity/models/number-property-metadata.model.ts +++ b/src/entity/models/number-property-metadata.model.ts @@ -2,6 +2,11 @@ import { BasePropertyMetadata, WithDefaultMetadata } from './base-property-metad import { AnyEnum } from '../../types/any-enum.type'; import { OmitStrict } from '../../types/omit-strict.type'; +/** + * The possible formats a number value can have. + */ +export type NumberFormat = 'integer' | 'bigint'; + /** * Metadata for number properties. */ @@ -19,6 +24,13 @@ export type NumberPropertyMetadata = BasePropertyMetadata & WithDefaultMetadata< * Whether or not the property should be unique. */ unique: boolean, + /** + * The format of the property, or undefined if none. + * + * CAUTION: + * The 'bigint' format is handled as a string/BigIntString, because the native bigint type causes problems with serialization into json. + */ + format: NumberFormat | undefined, /** * The minimum value of the property. */ diff --git a/src/entity/models/string-property-metadata.model.ts b/src/entity/models/string-property-metadata.model.ts index 9782643..147cff2 100644 --- a/src/entity/models/string-property-metadata.model.ts +++ b/src/entity/models/string-property-metadata.model.ts @@ -1,6 +1,80 @@ import { BasePropertyMetadata, WithDefaultMetadata } from './base-property-metadata.model'; +import { EncryptOptions } from '../../auth/encryption/encryption-service.interface'; +import { BaseDecryptOptions, BaseEncryptOptions, EncryptionStrategyInterface } from '../../auth/encryption/strategies/encryption-strategy.interface'; +import { HashOptions } from '../../auth/hash/hash-service.interface'; +import { HttpRequestContext } from '../../context/request/http-request.context'; +import { WebsocketRequestContext } from '../../context/request/websocket-request.context'; import { AnyEnum } from '../../types/any-enum.type'; +import { Newable } from '../../types/newable.type'; import { OmitStrict } from '../../types/omit-strict.type'; +import { AnyObject } from '../any-object.model'; + +/** + * Options for encrypt and decrypt. + */ +export type EncryptAndDecryptOptions< + Data, + TKey, + TEncryptOptions extends BaseEncryptOptions, + TDecryptOptions extends BaseDecryptOptions +> = { + /** + * The encryption strategy that should be used. + */ + strategy: Newable>, + /** + * The encrypt options. + */ + encrypt?: EncryptPropertyValue, + /** + * The decrypt options. + */ + decrypt?: DecryptPropertyValue> +}; + +/** + * Value for the encryption setting on a property. + */ +export type EncryptionPropertyValue< + Data, + TKey, + TEncryptOptions extends BaseEncryptOptions, + TDecryptOptions extends BaseDecryptOptions +> = boolean + | EncryptAndDecryptOptions + | ( + ( + data: Data, + ctx: HttpRequestContext | WebsocketRequestContext | undefined + ) => EncryptAndDecryptOptions + | Promise> + ); + +// eslint-disable-next-line jsdoc/require-jsdoc +type EncryptPropertyValue> = boolean + | EncryptOptions['strategyOptions'] + | ( + ( + data: Data, + ctx: HttpRequestContext | WebsocketRequestContext | undefined + ) => (EncryptOptions['strategyOptions'] | boolean) + | Promise['strategyOptions'] | boolean> + ); + +// eslint-disable-next-line jsdoc/require-jsdoc +type DecryptPropertyValue = boolean | T + | ((data: Data, ctx: HttpRequestContext | WebsocketRequestContext | undefined) => (boolean | T) | Promise); + +/** + * Value for the hash setting on a property. + */ +export type HashPropertyValue = boolean | HashOptions + | ( + ( + data: Data, + ctx: HttpRequestContext | WebsocketRequestContext | undefined + ) => HashOptions | Promise> + ); /** * The possible formats a string value can have. @@ -10,7 +84,13 @@ export type StringFormat = 'uuid' | 'email'; /** * Metadata for string properties. */ -export type StringPropertyMetadata = BasePropertyMetadata & WithDefaultMetadata & { +export type StringPropertyMetadata< + Data, + TKey, + TEncryptOptions extends BaseEncryptOptions, + TDecryptOptions extends BaseDecryptOptions, + THashOptions extends AnyObject +> = BasePropertyMetadata & WithDefaultMetadata & { /** * The type of the property. */ @@ -43,10 +123,24 @@ export type StringPropertyMetadata = BasePropertyMetadata & WithDefaultMetadata< /** * An enum that this property is one of. */ - enum: AnyEnum | undefined + enum: AnyEnum | undefined, + /** + * Settings for encrypting/decrypting this property. + */ + encryption: EncryptionPropertyValue, + /** + * Settings for hashing this property. + */ + hash: HashPropertyValue }; /** * Input Metadata for string properties. */ -export type StringPropertyMetadataInput = Partial>; \ No newline at end of file +export type StringPropertyMetadataInput< + Data, + TKey, + TEncryptOptions extends BaseEncryptOptions, + TDecryptOptions extends BaseDecryptOptions, + THashOptions extends AnyObject +> = Partial, 'type'>>; \ No newline at end of file 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/error-handling/unknown-to-error-string.function.ts b/src/error-handling/unknown-to-error-string.function.ts index 70fa2d2..c7cbdb2 100644 --- a/src/error-handling/unknown-to-error-string.function.ts +++ b/src/error-handling/unknown-to-error-string.function.ts @@ -1,4 +1,5 @@ import { isError } from './is-error.function'; +import { JsonUtilities } from '../utilities/json.utilities'; // eslint-disable-next-line jsdoc/require-jsdoc export function unknownToErrorString(error: unknown): string { @@ -6,5 +7,5 @@ export function unknownToErrorString(error: unknown): string { return error.message; } - return JSON.stringify(error); + return JsonUtilities.stringify(error); } \ No newline at end of file diff --git a/src/event/event-cleanup.cron-job.ts b/src/event/event-cleanup.cron-job.ts new file mode 100644 index 0000000..0d27a96 --- /dev/null +++ b/src/event/event-cleanup.cron-job.ts @@ -0,0 +1,43 @@ + +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'; + +/** + * CronJob that cleans up past events. + */ +export class EventCleanupCronJob extends CronJob { + // eslint-disable-next-line jsdoc/require-jsdoc + initialConfig: InitialCronConfig = { + name: 'Event Cleanup', + cron: CronExpression.daily().build(), + 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..8cf3c2d --- /dev/null +++ b/src/event/event-processing.error.ts @@ -0,0 +1,16 @@ +import { Event } from './event.model'; +import { JsonUtilities } from '../utilities/json.utilities'; + +/** + * 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:`, + JsonUtilities.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..0fd713c --- /dev/null +++ b/src/event/event.service.ts @@ -0,0 +1,252 @@ +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 { JsonUtilities } from '../utilities/json.utilities'; +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); + if (!app.options.cronJobs.includes(EventCleanupCronJob)) { + 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(JsonUtilities.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/global-registry.ts b/src/global/global-registry.ts index d040a2f..b7464b0 100644 --- a/src/global/global-registry.ts +++ b/src/global/global-registry.ts @@ -2,6 +2,7 @@ import { ZibriApplicationOptions } from '../application-options.model'; import { AppState } from './app-state.enum'; import { UserRepositories } from '../auth/models/user-repositories.model'; import { BackupResourceInterface } from '../backup/backup-resource.interface'; +import { AnyCache } from '../caching/cache/cache.interface'; import { DiProvider } from '../di/models/di-provider.model'; import { BaseEntity } from '../entity/base-entity.model'; import { BodyParserInterface } from '../parsing/body-parser.interface'; @@ -57,6 +58,10 @@ export abstract class GlobalRegistry { * All entities registered with \@Entity. */ static readonly entityClasses: Newable[] = []; + /** + * All entities registered with \@Cache. + */ + static readonly cacheClasses: Newable[] = []; /** * All backup resources registered with \@Backup. */ diff --git a/src/global/model-registry/decrypt-encryption-properties.function.ts b/src/global/model-registry/decrypt-encryption-properties.function.ts new file mode 100644 index 0000000..419291d --- /dev/null +++ b/src/global/model-registry/decrypt-encryption-properties.function.ts @@ -0,0 +1,102 @@ +import { EncryptionDescriptor } from './encryption-descriptor'; +import { ModelRegistry } from './model.registry'; +import { EncryptionServiceInterface } from '../../auth/encryption/encryption-service.interface'; +import { EncryptionString } from '../../auth/encryption/encryption.utilities'; +import { BaseDecryptOptions, BaseEncryptOptions } from '../../auth/encryption/strategies/encryption-strategy.interface'; +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 { EncryptAndDecryptOptions, EncryptionPropertyValue } from '../../entity/models/string-property-metadata.model'; +import { ExcludeStrict } from '../../types/exclude-strict.type'; +import { Newable } from '../../types/newable.type'; +import { OmitStrict } from '../../types/omit-strict.type'; + +/** + * Decrypts all properties defined with the encryption flag based on a pre-built EncryptionDescriptor. + * @param data - The entity instance to process. + * @param entityClass - The entity class to get the encryption properties from. + * @param encryptionService - The service responsible for encryption. + */ +export async function decryptEncryptionProperties( + data: Data, + entityClass: Newable, + encryptionService: EncryptionServiceInterface +): Promise { + if (data == undefined || typeof data !== 'object') { + return; + } + + const context: HttpRequestContext | WebsocketRequestContext | undefined = AlsUtilities.getCurrentRequestContext(); + const descriptor: EncryptionDescriptor = ModelRegistry.get(entityClass).encryptionDescriptor; + await decryptEncryptionPropertiesRecursive(context, data, descriptor, encryptionService); +} + +// eslint-disable-next-line jsdoc/require-jsdoc, sonar/cognitive-complexity +async function decryptEncryptionPropertiesRecursive( + context: HttpRequestContext | WebsocketRequestContext | undefined, + data: Data, + descriptor: EncryptionDescriptor, + encryptionService: EncryptionServiceInterface +): Promise { + if (data == undefined || typeof data !== 'object') { + return; + } + + for (const [key, encryptionOptions] of descriptor.keys) { + if (typeof (data as AnyObject)[key] !== 'string') { + continue; + } + + const options: boolean | AnyObject = await resolveDecryptOptions(context, data, encryptionOptions); + if (options === false) { + continue; + } + + const decrypted: string = await encryptionService.decrypt( + (data as AnyObject)[key] as EncryptionString, + options === true ? undefined : options + ); + (data as AnyObject)[key] = decrypted; + } + + 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 decryptEncryptionPropertiesRecursive(context, item, nested, encryptionService); + } + } + else { + await decryptEncryptionPropertiesRecursive(context, value, nested, encryptionService); + } + } +} + +// eslint-disable-next-line jsdoc/require-jsdoc +async function resolveDecryptOptions< + Data, + TKey, + TEncryptOptions extends BaseEncryptOptions, + TDecryptOptions extends BaseDecryptOptions +>( + context: HttpRequestContext | WebsocketRequestContext | undefined, + data: Data, + encryptionOptions: ExcludeStrict, false> +): Promise | boolean> { + const options: true | EncryptAndDecryptOptions = typeof encryptionOptions === 'function' + ? await encryptionOptions(data, context) + : encryptionOptions; + + if (options === true) { + return true; + } + if (options.decrypt == undefined) { + return true; + } + + return typeof options.decrypt === 'function' ? await options.decrypt(data, context) : options.decrypt; +} \ 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/encrypt-encryption-properties.function.ts b/src/global/model-registry/encrypt-encryption-properties.function.ts new file mode 100644 index 0000000..e2d1a6f --- /dev/null +++ b/src/global/model-registry/encrypt-encryption-properties.function.ts @@ -0,0 +1,108 @@ +import { EncryptionDescriptor } from './encryption-descriptor'; +import { ModelRegistry } from './model.registry'; +import { EncryptionServiceInterface, EncryptOptions } from '../../auth/encryption/encryption-service.interface'; +import { BaseDecryptOptions, BaseEncryptOptions } from '../../auth/encryption/strategies/encryption-strategy.interface'; +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 { EncryptAndDecryptOptions, EncryptionPropertyValue } from '../../entity/models/string-property-metadata.model'; +import { ExcludeStrict } from '../../types/exclude-strict.type'; +import { Newable } from '../../types/newable.type'; +import { OmitStrict } from '../../types/omit-strict.type'; + +/** + * Encrypts all properties defined with the encryption flag based on a pre-built EncryptionDescriptor. + * @param data - The entity instance to process. + * @param entityClass - The entity class to get the encryption properties from. + * @param encryptionService - The service responsible for encryption. + */ +export async function encryptEncryptionProperties( + data: Data, + entityClass: Newable, + encryptionService: EncryptionServiceInterface +): Promise { + if (data == undefined || typeof data !== 'object') { + return; + } + + const context: HttpRequestContext | WebsocketRequestContext | undefined = AlsUtilities.getCurrentRequestContext(); + const descriptor: EncryptionDescriptor = ModelRegistry.get(entityClass).encryptionDescriptor; + await encryptEncryptionPropertiesRecursive(context, data, descriptor, encryptionService); +} + +// eslint-disable-next-line jsdoc/require-jsdoc, sonar/cognitive-complexity +async function encryptEncryptionPropertiesRecursive( + context: HttpRequestContext | WebsocketRequestContext | undefined, + data: Data, + descriptor: EncryptionDescriptor, + encryptionService: EncryptionServiceInterface +): Promise { + for (const [key, encryptionOptions] of descriptor.keys) { + if (typeof (data as AnyObject)[key] !== 'string') { + continue; + } + + // eslint-disable-next-line typescript/no-explicit-any + const options: boolean | EncryptOptions = await resolveEncryptOptions(context, data, encryptionOptions); + if (options === false) { + continue; + } + + const encrypted: string = await encryptionService.encrypt( + (data as AnyObject)[key] as string, + options === true ? undefined : options + ); + (data as AnyObject)[key] = encrypted; + } + + 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 encryptEncryptionPropertiesRecursive(context, item, nested, encryptionService); + } + } + else { + await encryptEncryptionPropertiesRecursive(context, value, nested, encryptionService); + } + } +} + +// eslint-disable-next-line jsdoc/require-jsdoc +async function resolveEncryptOptions< + Data, + TKey, + TEncryptOptions extends BaseEncryptOptions +>( + context: HttpRequestContext | WebsocketRequestContext | undefined, + data: Data, + encryptionOptions: ExcludeStrict>, false> +): Promise | boolean> { + const options: true | EncryptAndDecryptOptions> + = typeof encryptionOptions === 'function' + ? await encryptionOptions(data, context) + : encryptionOptions; + + if (options === true) { + return true; + } + if (options.encrypt == undefined) { + return true; + } + + const strategyOptions: boolean | Partial> | undefined = typeof options.encrypt === 'function' + ? await options.encrypt(data, context) + : options.encrypt; + if (typeof strategyOptions === 'boolean') { + return strategyOptions; + } + + return { + strategy: options.strategy, + strategyOptions: strategyOptions + }; +} \ No newline at end of file diff --git a/src/global/model-registry/encryption-descriptor.ts b/src/global/model-registry/encryption-descriptor.ts new file mode 100644 index 0000000..b8abb98 --- /dev/null +++ b/src/global/model-registry/encryption-descriptor.ts @@ -0,0 +1,109 @@ +import { ModelRegistry } from './model.registry'; +import { PropertyMetadata } from '../../entity/decorators/property.decorator'; +import { ArrayPropertyItemMetadata } from '../../entity/models/array-property-metadata.model'; +import { Relation } from '../../entity/models/relation.enum'; +import { EncryptionPropertyValue } from '../../entity/models/string-property-metadata.model'; +import { ExcludeStrict } from '../../types/exclude-strict.type'; +import { Newable } from '../../types/newable.type'; +import { MetadataUtilities } from '../../utilities/metadata.utilities'; +import { ObjectUtilities } from '../../utilities/object.utilities'; + +/** + * A descriptor that keeps track of the encryption properties of an entity. + */ +export class EncryptionDescriptor { + /** + * Keys that have a encryption value of true or function. + */ + // eslint-disable-next-line typescript/no-explicit-any + readonly keys: Map, false>> = new Map(); + /** + * Keys with no encrypt option but containing nested entities that might have encrypt properties. + */ + readonly nestedKeys: Map> = new Map(); + + /** + * Updates this descriptor by the properties of the given class. + * @param entityClass - The class to get the encrypt properties from. + */ + update(entityClass: Newable): void { + this.keys.clear(); + this.nestedKeys.clear(); + + const properties: Record = MetadataUtilities.getModelProperties(entityClass); + + for (const [key, metadata] of ObjectUtilities.entries(properties)) { + if (('encryption' in metadata) && metadata.encryption !== undefined && metadata.encryption !== false) { + this.keys.set(key, metadata.encryption); + 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: EncryptionDescriptor = ModelRegistry.get(metadata.target()).encryptionDescriptor; + if (nested.hasAnything()) { + this.nestedKeys.set(key, nested); + } + break; + } + + case 'object': { + const nested: EncryptionDescriptor = ModelRegistry.get(metadata.cls()).encryptionDescriptor; + if (nested.hasAnything()) { + this.nestedKeys.set(key, nested); + } + break; + } + + case 'array': { + const nested: EncryptionDescriptor | undefined = this.buildEncryptDescriptorForArrayItems(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 encrypt property. + */ + hasAnything(): boolean { + return this.keys.size > 0 || this.nestedKeys.size > 0; + } + + private buildEncryptDescriptorForArrayItems( + items: ArrayPropertyItemMetadata + ): EncryptionDescriptor | undefined { + switch (items.type) { + case 'object': { + return ModelRegistry.get(items.cls()).encryptionDescriptor; + } + case 'array': { + return this.buildEncryptDescriptorForArrayItems(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..ab5dc20 --- /dev/null +++ b/src/global/model-registry/exclude-descriptor.ts @@ -0,0 +1,107 @@ +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 { ExcludeStrict } from '../../types/exclude-strict.type'; +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/hash-descriptor.ts b/src/global/model-registry/hash-descriptor.ts new file mode 100644 index 0000000..57a6916 --- /dev/null +++ b/src/global/model-registry/hash-descriptor.ts @@ -0,0 +1,109 @@ +import { ModelRegistry } from './model.registry'; +import { PropertyMetadata } from '../../entity/decorators/property.decorator'; +import { ArrayPropertyItemMetadata } from '../../entity/models/array-property-metadata.model'; +import { Relation } from '../../entity/models/relation.enum'; +import { HashPropertyValue } from '../../entity/models/string-property-metadata.model'; +import { ExcludeStrict } from '../../types/exclude-strict.type'; +import { Newable } from '../../types/newable.type'; +import { MetadataUtilities } from '../../utilities/metadata.utilities'; +import { ObjectUtilities } from '../../utilities/object.utilities'; + +/** + * A descriptor that keeps track of the hash properties of an entity. + */ +export class HashDescriptor { + /** + * Keys that have a hash value of true or function. + */ + // eslint-disable-next-line typescript/no-explicit-any + readonly keys: Map, false>> = new Map(); + /** + * Keys with no hash option but containing nested entities that might have hash properties. + */ + readonly nestedKeys: Map> = new Map(); + + /** + * Updates this descriptor by the properties of the given class. + * @param entityClass - The class to get the hash properties from. + */ + update(entityClass: Newable): void { + this.keys.clear(); + this.nestedKeys.clear(); + + const properties: Record = MetadataUtilities.getModelProperties(entityClass); + + for (const [key, metadata] of ObjectUtilities.entries(properties)) { + if (('hash' in metadata) && metadata.hash !== undefined && metadata.hash !== false) { + this.keys.set(key, metadata.hash); + 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: HashDescriptor = ModelRegistry.get(metadata.target()).hashDescriptor; + if (nested.hasAnything()) { + this.nestedKeys.set(key, nested); + } + break; + } + + case 'object': { + const nested: HashDescriptor = ModelRegistry.get(metadata.cls()).hashDescriptor; + if (nested.hasAnything()) { + this.nestedKeys.set(key, nested); + } + break; + } + + case 'array': { + const nested: HashDescriptor | undefined = this.buildHashDescriptorForArrayItems(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 hash property. + */ + hasAnything(): boolean { + return this.keys.size > 0 || this.nestedKeys.size > 0; + } + + private buildHashDescriptorForArrayItems( + items: ArrayPropertyItemMetadata + ): HashDescriptor | undefined { + switch (items.type) { + case 'object': { + return ModelRegistry.get(items.cls()).hashDescriptor; + } + case 'array': { + return this.buildHashDescriptorForArrayItems(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/hash-hash-properties.function.ts b/src/global/model-registry/hash-hash-properties.function.ts new file mode 100644 index 0000000..fbde640 --- /dev/null +++ b/src/global/model-registry/hash-hash-properties.function.ts @@ -0,0 +1,66 @@ +import { HashDescriptor } from './hash-descriptor'; +import { ModelRegistry } from './model.registry'; +import { HashOptions, HashServiceInterface } from '../../auth/hash/hash-service.interface'; +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'; + +/** + * Hashes all properties defined with the hash flag based on a pre-built HashDescriptor. + * @param data - The entity instance to process. + * @param entityClass - The entity class to get the hash properties from. + * @param hashService - The service responsible for hashing. + */ +export async function hashHashProperties( + data: Data, + entityClass: Newable, + hashService: HashServiceInterface +): Promise { + if (data == undefined || typeof data !== 'object') { + return; + } + + const context: HttpRequestContext | WebsocketRequestContext | undefined = AlsUtilities.getCurrentRequestContext(); + const descriptor: HashDescriptor = ModelRegistry.get(entityClass).hashDescriptor; + await hashHashPropertiesRecursive(context, data, descriptor, hashService); +} + +// eslint-disable-next-line jsdoc/require-jsdoc, sonar/cognitive-complexity +async function hashHashPropertiesRecursive( + context: HttpRequestContext | WebsocketRequestContext | undefined, + data: Data, + descriptor: HashDescriptor, + hashService: HashServiceInterface +): Promise { + for (const [key, hashOptions] of descriptor.keys) { + if (typeof (data as AnyObject)[key] !== 'string') { + continue; + } + const options: true | HashOptions = typeof hashOptions === 'function' + ? await hashOptions(data, context) + : hashOptions; + + const hashed: string = await hashService.hash( + (data as AnyObject)[key] as string, + options === true ? undefined : options + ); + (data as AnyObject)[key] = hashed; + } + + 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 hashHashPropertiesRecursive(context, item, nested, hashService); + } + } + else { + await hashHashPropertiesRecursive(context, value, nested, hashService); + } + } +} \ 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..712d049 --- /dev/null +++ b/src/global/model-registry/model.registry.ts @@ -0,0 +1,79 @@ +import { DefaultDescriptor } from './default-descriptor'; +import { EncryptionDescriptor } from './encryption-descriptor'; +import { ExcludeDescriptor } from './exclude-descriptor'; +import { HashDescriptor } from './hash-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 encryption descriptor for this class. + */ + readonly encryptionDescriptor: EncryptionDescriptor, + /** + * Pre-built hash descriptor for this class. + */ + readonly hashDescriptor: HashDescriptor, + /** + * 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(), + encryptionDescriptor: new EncryptionDescriptor(), + hashDescriptor: new HashDescriptor(), + defaultDescriptor: new DefaultDescriptor() + }; + this.cache.set(entityClass, modelRegistryData); + } + + // Resolve inherited properties first, so descriptors see the full picture + // modelRegistryData.properties = ; + modelRegistryData.encryptionDescriptor.update(entityClass); + modelRegistryData.hashDescriptor.update(entityClass); + 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/handlebars/handlebar.utilities.ts b/src/handlebars/handlebar.utilities.ts index f837e3f..22b53c5 100644 --- a/src/handlebars/handlebar.utilities.ts +++ b/src/handlebars/handlebar.utilities.ts @@ -2,6 +2,7 @@ import handlebars, { ParseOptions } from 'handlebars'; import { AstProgram } from './ast.model'; import { FsUtilities, FsPath } from '../utilities/fs.utilities'; +import { JsonUtilities } from '../utilities/json.utilities'; import { MaskUtilities } from '../utilities/mask.utilities'; import { toCamelCase } from '../utilities/to-camel-case.function'; @@ -18,7 +19,7 @@ export abstract class HandlebarUtilities { */ static async init(H: typeof Handlebars, componentsDir: string): Promise { this.H = H; - this.registerHelper('json', (context) => JSON.stringify(context)); + this.registerHelper('json', (context) => JsonUtilities.stringify(context)); this.registerHelper('concat', (...args: unknown[]) => { args.pop(); return args.join(''); diff --git a/src/handlebars/resolve-all-array-keys.function.ts b/src/handlebars/resolve-all-array-keys.function.ts index a436d67..b34f5d1 100644 --- a/src/handlebars/resolve-all-array-keys.function.ts +++ b/src/handlebars/resolve-all-array-keys.function.ts @@ -1,5 +1,6 @@ import { AstBlockStatement, AstExpression, AstProgram, AstStatement } from './ast.model'; import { resolveKeyForPathExpression } from './resolve-key-for-path-expression.function'; +import { JsonUtilities } from '../utilities/json.utilities'; // eslint-disable-next-line jsdoc/require-jsdoc export function resolveAllArrayKeys(ast: AstProgram, parentKey: string | undefined): string[] { @@ -61,7 +62,7 @@ function resolveArrayKeysForBlockStatement(element: AstBlockStatement, parentKey // eslint-disable-next-line jsdoc/require-jsdoc function getArrayKeyFromArrayParams(params: AstExpression[], parentKey: string | undefined): string { if (params.length !== 1) { - throw new Error(`Got more than 1 param ${JSON.stringify(params)}`); + throw new Error(`Got more than 1 param ${JsonUtilities.stringify(params)}`); } switch (params[0].type) { diff --git a/src/handlebars/resolve-keys-for-block-statement.function.ts b/src/handlebars/resolve-keys-for-block-statement.function.ts index d84e81a..762747c 100644 --- a/src/handlebars/resolve-keys-for-block-statement.function.ts +++ b/src/handlebars/resolve-keys-for-block-statement.function.ts @@ -1,6 +1,7 @@ import { AstBlockStatement, AstExpression } from './ast.model'; import { resolveKeyForPathExpression } from './resolve-key-for-path-expression.function'; import { resolveAllKeys } from './resolve-tree.function'; +import { JsonUtilities } from '../utilities/json.utilities'; // eslint-disable-next-line jsdoc/require-jsdoc export function resolveKeysForBlockStatement(element: AstBlockStatement, parentKey: string | undefined): string[] { @@ -28,8 +29,8 @@ export function resolveKeysForBlockStatement(element: AstBlockStatement, parentK case 'with': case 'log': { throw new Error(`Not implemented yet "${element.path.original}"`); - res.push(...resolveAllKeys(element.program, parentKey)); - break; + // res.push(...resolveAllKeys(element.program, parentKey)); + // break; } default: { throw new Error(`Unknown AST path.original "${element.path.original}"`); @@ -41,7 +42,7 @@ export function resolveKeysForBlockStatement(element: AstBlockStatement, parentK // eslint-disable-next-line jsdoc/require-jsdoc function getKeyFromArrayParams(params: AstExpression[], parentKey: string | undefined): string { if (params.length !== 1) { - throw new Error(`Got more than 1 param ${JSON.stringify(params)}`); + throw new Error(`Got more than 1 param ${JsonUtilities.stringify(params)}`); } switch (params[0].type) { @@ -70,7 +71,7 @@ function getKeyFromArrayParams(params: AstExpression[], parentKey: string | unde // eslint-disable-next-line jsdoc/require-jsdoc function getKeyFromIfParams(params: AstExpression[], parentKey: string | undefined): string { if (params.length !== 1) { - throw new Error(`Got more than 1 param ${JSON.stringify(params)}`); + throw new Error(`Got more than 1 param ${JsonUtilities.stringify(params)}`); } switch (params[0].type) { 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.test.ts b/src/http-client/http-client.test.ts index 5772d8e..c76c504 100644 --- a/src/http-client/http-client.test.ts +++ b/src/http-client/http-client.test.ts @@ -16,6 +16,7 @@ import { FormDataBodyParser } from '../parsing/form-data/form-data.body-parser'; import { FormData } from '../parsing/form-data/form-data.model'; import { JsonBodyParser } from '../parsing/json/json.body-parser'; import { Parser } from '../parsing/parser'; +import { JsonUtilities } from '../utilities/json.utilities'; class Item { @Property.string() @@ -49,15 +50,15 @@ describe('post', () => { }); app.post('/form-data/valid', (_req, res) => { const form: NodeFormData = new NodeFormData(); - form.append('file', JSON.stringify({ hello: 'world' }), { + form.append('file', JsonUtilities.stringify({ hello: 'world' }), { filename: 'payload.json', contentType: MimeType.JSON }); - form.append('files', JSON.stringify({ hello: 'world2' }), { + form.append('files', JsonUtilities.stringify({ hello: 'world2' }), { filename: 'files.json', contentType: MimeType.JSON }); - form.append('files', JSON.stringify({ hello: 'world3' }), { + form.append('files', JsonUtilities.stringify({ hello: 'world3' }), { filename: 'files.json', contentType: MimeType.JSON }); @@ -67,7 +68,7 @@ describe('post', () => { }); app.post('/form-data/invalid', (_req, res) => { const form: NodeFormData = new NodeFormData(); - form.append('file', JSON.stringify({ hello: 'world' }), { + form.append('file', JsonUtilities.stringify({ hello: 'world' }), { filename: 'payload.json', contentType: MimeType.JSON }); 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/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 8fe6bdf..ddd3c44 100644 --- a/src/http/known-header.enum.ts +++ b/src/http/known-header.enum.ts @@ -4,31 +4,35 @@ 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', + 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', + 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 +41,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/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 819f556..abdd9a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,7 +22,6 @@ export * from './auth/models/require-2fa-metadata.model'; export * from './auth/auth-service.interface'; export * from './auth/auth.service'; -export * from './auth/hash.utilities'; export * from './auth/auth-controller.interface'; export * from './auth/strategies/jwt/jwt-access-token-payload.model'; @@ -37,6 +36,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'; @@ -54,6 +66,24 @@ export * from './auth/2fa/methods/otp/otp.two-factor-method'; export * from './auth/2fa/methods/otp/otp-credentials.model'; export * from './auth/2fa/methods/otp/otp.utilities'; +export * from './auth/encryption/encryption-key.model'; +export * from './auth/encryption/encryption-master-options.model'; +export * from './auth/encryption/encryption-service.interface'; +export * from './auth/encryption/encryption.service'; +export * from './auth/encryption/encryption.utilities'; + +export * from './auth/encryption/strategies/aes-gcm.encryption-strategy'; +export * from './auth/encryption/strategies/encryption-strategy-entity.model'; +export * from './auth/encryption/strategies/encryption-strategy.interface'; + +export * from './auth/hash/hash-service.interface'; +export * from './auth/hash/hash.service'; +export * from './auth/hash/hash.utilities'; + +export * from './auth/hash/strategies/bcrypt.hash-strategy'; +export * from './auth/hash/strategies/hash-strategy-entity.model'; +export * from './auth/hash/strategies/hash-strategy.interface'; + // di export * from './di/decorators/injectable.decorator'; export * from './di/decorators/inject.decorator'; @@ -71,6 +101,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 +131,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 +172,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'; @@ -186,6 +233,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'; @@ -200,6 +248,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'; @@ -246,11 +295,16 @@ export * from './data-source/models/where/string-where-filter.model'; export * from './data-source/migration/migration.model'; export * from './data-source/migration/migration-entity.model'; +export * from './data-source/hooks/hooks.default'; +export * from './data-source/hooks/before-return'; +export * from './data-source/hooks/before-save'; + // cron 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'; @@ -288,6 +342,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'; @@ -439,18 +494,62 @@ export * from './http-client/http-client.interface'; export * from './http-client/http-client-response.model'; export * from './http-client/http-client.error'; +// caching +export * from './caching/cache-metrics.model'; +export * from './caching/cache-service.interface'; +export * from './caching/cache-tag-matchers'; +export * from './caching/cache.service'; + +export * from './caching/cache/base-cache.model'; +export * from './caching/cache/cache-operation.enum'; +export * from './caching/cache/cache-options.model'; +export * from './caching/cache/cache.interface'; + +export * from './caching/cache/read-aside/read-aside.cache'; +export * from './caching/cache/read-aside/write-around-read-aside.cache'; +export * from './caching/cache/read-aside/write-behind-read-aside.cache'; +export * from './caching/cache/read-aside/write-invalidate-read-aside-args-only.cache'; +export * from './caching/cache/read-aside/write-invalidate-read-aside-with-result.cache'; +export * from './caching/cache/read-aside/write-through-read-aside.cache'; + +export * from './caching/cache/read-through/read-through.cache'; +export * from './caching/cache/read-through/write-around-read-through.cache'; +export * from './caching/cache/read-through/write-behind-read-through.cache'; +export * from './caching/cache/read-through/write-invalidate-read-through-args-only.cache'; +export * from './caching/cache/read-through/write-invalidate-read-through-with-result.cache'; +export * from './caching/cache/read-through/write-through-read-through.cache'; + +export * from './caching/decorators/cache-delete.decorator'; +export * from './caching/decorators/cache-invalidate.decorator'; +export * from './caching/decorators/cache-write.decorator'; +export * from './caching/decorators/cache.decorator'; +export * from './caching/decorators/cached.decorator'; + +export * from './caching/store/cache-store.interface'; +export * from './caching/store/cached-value.model'; +export * from './caching/store/in-memory.cache-store'; + // 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 +export * from './utilities/bytes'; +export * from './utilities/doubly-linked-list'; export * from './utilities/compare-versions.function'; export * from './utilities/is-version.function'; +export * from './utilities/now-in-ns.function'; export * from './utilities/promise.utilities'; export * from './utilities/ms'; -export * from './utilities/big-number.utilities'; +export * from './utilities/number.utilities'; export * from './utilities/validate-entities-registered.function'; export * from './utilities/validate-tokens-registered.function'; export * from './utilities/uuid.utilities'; export * from './utilities/mask.utilities'; -export * from './utilities/fs.utilities'; \ No newline at end of file +export * from './utilities/fs.utilities'; +export * from './utilities/json.utilities'; \ No newline at end of file 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-fn.model.ts b/src/localization/formatting/format-price-fn.model.ts index 036320d..8b0326f 100644 --- a/src/localization/formatting/format-price-fn.model.ts +++ b/src/localization/formatting/format-price-fn.model.ts @@ -1,4 +1,4 @@ -import { BigNumber } from '../../utilities/big-number.utilities'; +import { BigNumber } from '../../utilities/number.utilities'; import { CurrencyCode } from '../models/currency-code.model'; import { LanguageCode } from '../models/language-code.model'; diff --git a/src/localization/formatting/format-price.function.ts b/src/localization/formatting/format-price.function.ts index 3c75b2e..810c761 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/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/error-to-logged-error.function.ts b/src/logging/error-to-logged-error.function.ts index e78cd7a..7db830a 100644 --- a/src/logging/error-to-logged-error.function.ts +++ b/src/logging/error-to-logged-error.function.ts @@ -1,5 +1,6 @@ /* eslint-disable jsdoc/require-jsdoc */ import { LoggedError } from './logged-error.model'; +import { JsonUtilities } from '../utilities/json.utilities'; export function errorToLoggedError(error: Error): LoggedError { const res: LoggedError = { @@ -26,7 +27,7 @@ function errorToParagraphs(error: Error, indent: string = ''): string[] { paragraphs.push(...errorToParagraphs(error.cause, newIndent)); } else { - paragraphs.push(`${newIndent}caused by:`, ...JSON.stringify(error.cause, undefined, 2).split('\n')); + paragraphs.push(`${newIndent}caused by:`, ...JsonUtilities.stringify(error.cause, undefined, 2).split('\n')); } } 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/log-context.model.ts b/src/logging/log-context.model.ts index 55a9ad8..349fb4b 100644 --- a/src/logging/log-context.model.ts +++ b/src/logging/log-context.model.ts @@ -1,8 +1,17 @@ +import { LoggedError } from './logged-error.model'; +import { CacheOperation } from '../caching/cache/cache-operation.enum'; import { Property } from '../entity/decorators/property.decorator'; import { HttpMethod } from '../http/http-method.enum'; import { HttpStatus } from '../http/http-status.enum'; import { OmitStrict } from '../types/omit-strict.type'; +/** + * Any custom additional metadata for the log context. + */ +export class LogContextMetadata implements Record { + [key: string]: unknown +} + /** * Context information about a request that triggered a log. */ @@ -39,6 +48,37 @@ export class LogRequestContext { durationInMs?: number; } +/** + * Context information about a cache that triggered a log. + */ +export class LogCacheContext { + /** + * The name of the cache. + */ + @Property.string() + cache!: string; + /** + * The cache operation currently running. + */ + @Property.string({ enum: CacheOperation }) + operation!: CacheOperation; + /** + * The key of the value in the cache. + */ + @Property.unknown({ required: false }) + key?: unknown; + /** + * Whether or not the cache has been hit. + */ + @Property.boolean({ required: false }) + hit?: boolean; + /** + * The duration that the original function took. + */ + @Property.number({ required: false }) + durationInMs?: number; +} + /** * Context for the log, like the request, the id of the user if applicable and the stack trace. */ @@ -48,14 +88,34 @@ export class LogContext { */ @Property.string() origin!: string; + /** + * Any custom additional metadata for the log context. + */ + @Property.object({ cls: () => LogContextMetadata, required: false, allowAdditionalProperties: true }) + metadata?: LogContextMetadata; /** * Context information about the request that triggered the log. */ @Property.object({ cls: () => LogRequestContext, required: false }) request?: LogRequestContext; + /** + * Context information about the cache that triggered the log. + */ + @Property.array({ items: { type: 'object', cls: () => LogCacheContext }, required: false }) + cache?: LogCacheContext[]; + /** + * An error associated to this log. + */ + @Property.object({ cls: () => LoggedError, required: false }) + error?: LoggedError; } /** * The input for creating a new log context. */ -export type LogContextInput = OmitStrict; \ No newline at end of file +export type LogContextInput = OmitStrict & { + /** + * An error associated to this log. + */ + error?: unknown +}; \ No newline at end of file diff --git a/src/logging/log.model.ts b/src/logging/log.model.ts index b4efe5e..958d9f1 100644 --- a/src/logging/log.model.ts +++ b/src/logging/log.model.ts @@ -1,6 +1,5 @@ import { LogContext } from './log-context.model'; import { LogLevel } from './log-level.enum'; -import { LoggedError } from './logged-error.model'; import { BaseEntity } from '../entity/base-entity.model'; import { Entity } from '../entity/decorators/entity.decorator'; import { Property } from '../entity/decorators/property.decorator'; @@ -20,11 +19,6 @@ export class Log extends BaseEntity { */ @Property.string() message!: string; - /** - * An error logged by LogLevel.ERROR and LogLevel.CRITICAL. - */ - @Property.object({ cls: () => LoggedError, required: false }) - error?: LoggedError; /** * The context of the log. */ diff --git a/src/logging/logger.interface.ts b/src/logging/logger.interface.ts index de587e5..dcf79d3 100644 --- a/src/logging/logger.interface.ts +++ b/src/logging/logger.interface.ts @@ -1,4 +1,5 @@ import { LogContextInput } from './log-context.model'; +import { OmitStrict } from '../types/omit-strict.type'; import { BaseLoggerTransportConfig, LoggerTransport } from './transport/logger-transport.model'; /** @@ -24,9 +25,9 @@ export interface LoggerInterface { /** * Logs a error. */ - error: (error: Error, context?: LogContextInput) => void | Promise, + error: (error: Error, context?: OmitStrict) => void | Promise, /** * Logs a critical error. */ - critical: (error: Error, context?: LogContextInput) => void | Promise + critical: (error: Error, context?: OmitStrict) => void | Promise } \ No newline at end of file diff --git a/src/logging/logger.ts b/src/logging/logger.ts index f034678..7ec999b 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -1,16 +1,22 @@ 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 { isError } from '../error-handling/is-error.function'; import { GlobalRegistry } from '../global/global-registry'; import { OnAppInit } from '../global/on-app-init.interface'; +import { KnownHeader } from '../http/known-header.enum'; +import { OmitStrict } from '../types/omit-strict.type'; import { UUIDUtilities } from '../utilities/uuid.utilities'; /** @@ -28,35 +34,37 @@ 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 async debug(message: string, context?: LogContextInput): Promise { - await this.log(LogLevel.DEBUG, message, undefined, context); + await this.log(LogLevel.DEBUG, message, context); } // eslint-disable-next-line jsdoc/require-jsdoc async info(message: string, context?: LogContextInput): Promise { - await this.log(LogLevel.INFO, message, undefined, context); + await this.log(LogLevel.INFO, message, context); } // eslint-disable-next-line jsdoc/require-jsdoc async warn(message: string, context?: LogContextInput): Promise { - await this.log(LogLevel.WARN, message, undefined, context); + await this.log(LogLevel.WARN, message, context); } // eslint-disable-next-line jsdoc/require-jsdoc - async error(error: Error, context?: LogContextInput): Promise { - await this.log(LogLevel.ERROR, error.message, error, context); + async error(error: Error, context?: OmitStrict): Promise { + await this.log(LogLevel.ERROR, error.message, { ...context, error }); } // eslint-disable-next-line jsdoc/require-jsdoc - async critical(error: Error, context?: LogContextInput): Promise { - await this.log(LogLevel.CRITICAL, error.message, error, context); + async critical(error: Error, context?: OmitStrict): Promise { + await this.log(LogLevel.CRITICAL, error.message, { ...context, error }); } - private async log(level: LogLevel, message: string, error: Error | undefined, context: LogContextInput | undefined): Promise { + private async log(level: LogLevel, message: string, context: LogContextInput | undefined): Promise { if (!this.transports.find(t => t.config.level <= level)) { return; } @@ -65,13 +73,35 @@ 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 error: Error | undefined; + if (context && 'error' in context) { + error = isError(context.error) ? context.error : new Error('Error'); + } + let request: LogRequestContext | undefined; + const requestContext: HttpRequestContext | WebsocketRequestContext | undefined = inject(ZIBRI_DI_TOKENS.CURRENT_REQUEST_CONTEXT); + if (requestContext?.type === 'http-request') { + request = { + status: requestContext.request.res?.statusCode, + // TODO + // durationInMs: currentRequest.res?.app, + method: requestContext.request.method, + 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: { + ...context, + origin, + request, + error: error ? errorToLoggedError(error) : undefined, + cache: inject(ZIBRI_DI_TOKENS.CURRENT_CACHE_CONTEXT) + }, level }; diff --git a/src/logging/transport/log-to-console.function.ts b/src/logging/transport/log-to-console.function.ts index 3daf34c..f245452 100644 --- a/src/logging/transport/log-to-console.function.ts +++ b/src/logging/transport/log-to-console.function.ts @@ -27,14 +27,14 @@ export const logToConsole: LoggerTransportSend = (log } case LogLevel.ERROR: { console.error(timeStamp, `${red}${bright}ERROR${reset}${spacing}${log.context.origin}`); - for (const p of log.error?.paragraphs ?? []) { + for (const p of log.context.error?.paragraphs ?? []) { console.error(p); } return; } case LogLevel.CRITICAL: { console.error(timeStamp, `${purple}${bright}CRITICAL${reset}${spacing}${log.context.origin}`); - for (const p of log.error?.paragraphs ?? []) { + for (const p of log.context.error?.paragraphs ?? []) { console.error(p); } return; 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/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..cb520b5 100644 --- a/src/multithreading/services/multithreading.service.ts +++ b/src/multithreading/services/multithreading.service.ts @@ -17,6 +17,7 @@ import { OnAppShutdown } from '../../global/on-app-shutdown.interface'; import { type LoggerInterface } from '../../logging/logger.interface'; import { OmitStrict } from '../../types/omit-strict.type'; import { FsUtilities, FsPath } from '../../utilities/fs.utilities'; +import { JsonUtilities } from '../../utilities/json.utilities'; import { UUIDUtilities } from '../../utilities/uuid.utilities'; import { BaseFunctionThreadJobWorkerData, BaseThreadJobWorkerData } from '../models/base-thread-job-worker-data.model'; import { type MultithreadingOptions } from '../models/multithreading-options.model'; @@ -88,7 +89,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); } @@ -321,7 +322,7 @@ export class MultithreadingService implements MultithreadingServiceInterface, On } private async handleWorkerMessage(message: MessageType, threadId: number): Promise { - await this.logger.debug(`got message from worker:\n${JSON.stringify(message, undefined, 2)}`); + await this.logger.debug(`got message from worker:\n${JsonUtilities.stringify(message, undefined, 2)}`); const job: ThreadJob | undefined = this.getJobByThreadId(threadId); if (!job) { diff --git a/src/open-api/open-api.service.ts b/src/open-api/open-api.service.ts index 7ba66a6..8a8f569 100644 --- a/src/open-api/open-api.service.ts +++ b/src/open-api/open-api.service.ts @@ -37,6 +37,7 @@ import { RouteHandler } from '../routing/route-configuration.model'; import { type RouterInterface } from '../routing/router.interface'; import { Newable } from '../types/newable.type'; import { FsUtilities, FsPath } from '../utilities/fs.utilities'; +import { JsonUtilities } from '../utilities/json.utilities'; import { MetadataUtilities } from '../utilities/metadata.utilities'; import { ObjectUtilities } from '../utilities/object.utilities'; @@ -140,7 +141,7 @@ export class OpenApiService implements OpenApiServiceInterface, OnAppInit { res.type('.js').send([ 'window.onload = function() {', ' SwaggerUIBundle({', - ` spec: ${JSON.stringify(definition)},`, + ` spec: ${JsonUtilities.stringify(definition)},`, ' dom_id: \'#swagger-ui\',', ' presets: [', ' SwaggerUIBundle.presets.apis,', @@ -448,7 +449,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 +491,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 +544,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 +553,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 +612,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 +640,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 +658,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 +771,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/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 7d9f471..4d7b353 100644 --- a/src/parsing/form-data/form-data.body-parser.ts +++ b/src/parsing/form-data/form-data.body-parser.ts @@ -21,9 +21,9 @@ 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 { FsUtilities, FsPath } from '../../utilities/fs.utilities'; import { MetadataUtilities } from '../../utilities/metadata.utilities'; +import { BigNumber, NumberUtilities } from '../../utilities/number.utilities'; import { UUIDUtilities } from '../../utilities/uuid.utilities'; import { BodyParser } from '../decorators/body-parser.decorator'; import { parseArray } from '../functions/parse-array.function'; @@ -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 @@ -84,8 +86,8 @@ export class FormDataBodyParser implements BodyParserInterface, OnAppInit { if (metadata.type !== MimeType.FORM_DATA) { throw new Error(`${metadata.type} is not supported`); } - const contentLength: string | undefined = headers[KnownHeader.CONTENT_LENGTH] ?? headers[KnownHeader.CONTENT_LENGTH]; - if (contentLength && BigNumberUtilities.new(Number(contentLength)).isGreaterThan(metadata.maxSize)) { + const contentLength: string | undefined = headers[KnownHeader.CONTENT_LENGTH]; + if (contentLength && NumberUtilities.new(Number(contentLength)).isGreaterThan(metadata.maxSize)) { throw new ContentTooLargeError(); } @@ -133,10 +135,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 +189,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); } } @@ -258,7 +259,7 @@ export class FormDataBodyParser implements BodyParserInterface, OnAppInit { const filesMap: Record = {}; const filePromises: Promise[] = []; - let received: BigNumber = BigNumberUtilities.new(0); + let received: BigNumber = NumberUtilities.new(0); let aborted: boolean = false; bb.on('field', (name: string, val: string) => { @@ -267,7 +268,7 @@ export class FormDataBodyParser implements BodyParserInterface, OnAppInit { } const bytes: number = Buffer.byteLength(val, 'utf8'); - received = BigNumberUtilities.add(received, bytes); + received = NumberUtilities.add(received, bytes); if (received.isGreaterThan(metadata.maxSize)) { aborted = true; // stop parsing and abort @@ -303,7 +304,7 @@ export class FormDataBodyParser implements BodyParserInterface, OnAppInit { return; } - received = BigNumberUtilities.add(received, chunk.length); + received = NumberUtilities.add(received, chunk.length); if (received.isGreaterThan(metadata.maxSize)) { aborted = true; writeStream.destroy(); diff --git a/src/parsing/functions/parse-array.function.ts b/src/parsing/functions/parse-array.function.ts index 16b8e50..1c96d16 100644 --- a/src/parsing/functions/parse-array.function.ts +++ b/src/parsing/functions/parse-array.function.ts @@ -5,6 +5,7 @@ import { parseObject } from './parse-object.function'; import { parseString } from './parse-string.function'; import { ArrayPropertyItemMetadata } from '../../entity/models/array-property-metadata.model'; import { ArrayParamItemMetadata } from '../../routing/models/array-param-metadata.model'; +import { JsonUtilities } from '../../utilities/json.utilities'; // eslint-disable-next-line jsdoc/require-jsdoc export function parseArray( @@ -18,7 +19,7 @@ export function parseArray( let simpleParsedValue: unknown = rawValue; try { if (typeof rawValue === 'string') { - simpleParsedValue = JSON.parse(rawValue); + simpleParsedValue = JsonUtilities.parse(rawValue); } } catch { diff --git a/src/parsing/functions/parse-number.function.ts b/src/parsing/functions/parse-number.function.ts index fee9b94..5500fce 100644 --- a/src/parsing/functions/parse-number.function.ts +++ b/src/parsing/functions/parse-number.function.ts @@ -1,9 +1,32 @@ import { isNumeric } from '../../utilities/is-numeric.function'; +/** + * Regex that matches integers. + */ +export const INTEGER_REGEX: RegExp = /^-?(0|[1-9]\d*)$/; + // eslint-disable-next-line jsdoc/require-jsdoc export function parseNumber(rawValue: unknown): unknown { if (!isNumeric(rawValue)) { return rawValue; } + + if (typeof rawValue !== 'string') { + return rawValue; + } + + if (!INTEGER_REGEX.test(rawValue)) { + const asNumber: number = Number(rawValue); + return Number.isFinite(asNumber) ? asNumber : rawValue; + } + + const asBigInt: bigint = BigInt(rawValue); + if ( + asBigInt > BigInt(Number.MAX_SAFE_INTEGER) + || asBigInt < BigInt(Number.MIN_SAFE_INTEGER) + ) { + return asBigInt; + } + return Number(rawValue); } \ No newline at end of file diff --git a/src/parsing/functions/parse-object.function.ts b/src/parsing/functions/parse-object.function.ts index adc4aa3..ce261fb 100644 --- a/src/parsing/functions/parse-object.function.ts +++ b/src/parsing/functions/parse-object.function.ts @@ -6,6 +6,7 @@ import { parseString } from './parse-string.function'; import { PropertyMetadata } from '../../entity/decorators/property.decorator'; import { Relation } from '../../entity/models/relation.enum'; import { Newable } from '../../types/newable.type'; +import { JsonUtilities } from '../../utilities/json.utilities'; import { MetadataUtilities } from '../../utilities/metadata.utilities'; import { ObjectUtilities } from '../../utilities/object.utilities'; @@ -21,7 +22,7 @@ export function parseObject( let simpleParsedValue: unknown = rawValue; try { if (typeof rawValue === 'string') { - simpleParsedValue = JSON.parse(rawValue); + simpleParsedValue = JsonUtilities.parse(rawValue); } } catch { diff --git a/src/parsing/functions/parse-object.test.ts b/src/parsing/functions/parse-object.test.ts index 5e8c2b8..dc96e58 100644 --- a/src/parsing/functions/parse-object.test.ts +++ b/src/parsing/functions/parse-object.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from '@jest/globals'; import { parseObject } from './parse-object.function'; import { Property } from '../../entity/decorators/property.decorator'; +import { JsonUtilities } from '../../utilities/json.utilities'; class Dummy {} @@ -13,7 +14,7 @@ class Dummy2 { describe('parseObject', () => { it('parses date correctly', () => { - const parsedObject: { testDate: Date } = parseObject(JSON.stringify({ testDate: new Date() }), Dummy2) as { testDate: Date }; + const parsedObject: { testDate: Date } = parseObject(JsonUtilities.stringify({ testDate: new Date() }), Dummy2) as { testDate: Date }; expect(parsedObject.testDate).toBeInstanceOf(Date); }); 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/parsing/json/json.body-parser.ts b/src/parsing/json/json.body-parser.ts index 71cf1da..c239c03 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, NumberUtilities } from '../../utilities/number.utilities'; import { WebsocketRequest } from '../../websocket/models/websocket-request.model'; import { BodyParserInterface } from '../body-parser.interface'; import { BodyParser } from '../decorators/body-parser.decorator'; @@ -63,17 +63,17 @@ export class JsonBodyParser implements BodyParserInterface { return req.body; } const contentLength: string | undefined = req.headers[KnownHeader.CONTENT_LENGTH]; - if (contentLength && BigNumberUtilities.new(Number(contentLength)).isGreaterThan(metadata.maxSize)) { + if (contentLength && NumberUtilities.new(Number(contentLength)).isGreaterThan(metadata.maxSize)) { throw new ContentTooLargeError(); } const chunks: Buffer[] = []; - let received: BigNumber = BigNumberUtilities.new(0); + let received: BigNumber = NumberUtilities.new(0); await new Promise((resolve, reject) => { // eslint-disable-next-line typescript/typedef const onData = (chunk: Buffer): void => { - received = BigNumberUtilities.add(received, chunk.length); + received = NumberUtilities.add(received, chunk.length); if (received.isGreaterThan(metadata.maxSize)) { // eslint-disable-next-line typescript/no-use-before-define cleanup(); diff --git a/src/plugin/invoicing/services/invoice-calc-service.interface.ts b/src/plugin/invoicing/services/invoice-calc-service.interface.ts index 43aa0d4..e39f3a4 100644 --- a/src/plugin/invoicing/services/invoice-calc-service.interface.ts +++ b/src/plugin/invoicing/services/invoice-calc-service.interface.ts @@ -1,5 +1,5 @@ -import { BigNumber } from '../../../utilities/big-number.utilities'; +import { BigNumber } from '../../../utilities/number.utilities'; import { InvoiceItem } from '../models/invoice-item.model'; import { Invoice as BaseInvoice } from '../models/invoice.model'; import { Vat } from '../models/vat.model'; diff --git a/src/plugin/invoicing/services/invoice-calc.service.ts b/src/plugin/invoicing/services/invoice-calc.service.ts index d0e9c0b..0082b72 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, NumberUtilities } from '../../../utilities/number.utilities'; import { InvoiceItem } from '../models/invoice-item.model'; import { Invoice as BaseInvoice, Invoice } from '../models/invoice.model'; import { Vat } from '../models/vat.model'; @@ -13,43 +13,43 @@ import { Vat } from '../models/vat.model'; export class InvoiceCalcService implements InvoiceCalcServiceInterface { // eslint-disable-next-line jsdoc/require-jsdoc getItemTotalPriceBeforeTax(item: InvoiceItem): BigNumber { - return BigNumberUtilities.multiply(item.amount, item.price); + return NumberUtilities.multiply(item.amount, item.price); } // eslint-disable-next-line jsdoc/require-jsdoc getItemTotalPriceAfterTax(item: InvoiceItem): BigNumber { const itemTotalBeforeTax: BigNumber = this.getItemTotalPriceBeforeTax(item); - const taxMultiplier: BigNumber = BigNumberUtilities.add(1, BigNumberUtilities.divide(item.vat.rate, 100)); - return BigNumberUtilities.multiply(itemTotalBeforeTax, taxMultiplier); + const taxMultiplier: BigNumber = NumberUtilities.add(1, NumberUtilities.divide(item.vat.rate, 100)); + return NumberUtilities.multiply(itemTotalBeforeTax, taxMultiplier); } // eslint-disable-next-line jsdoc/require-jsdoc getItemTotalTax(item: InvoiceItem): BigNumber { const itemTotalBeforeTax: BigNumber = this.getItemTotalPriceBeforeTax(item); - return BigNumberUtilities.multiply(itemTotalBeforeTax, BigNumberUtilities.divide(item.vat.rate, 100)); + return NumberUtilities.multiply(itemTotalBeforeTax, NumberUtilities.divide(item.vat.rate, 100)); } // eslint-disable-next-line jsdoc/require-jsdoc getTotalBeforeTax(invoice: Invoice): BigNumber { - let res: BigNumber = BigNumberUtilities.new(0); + let res: BigNumber = NumberUtilities.new(0); for (const item of invoice.items) { - res = BigNumberUtilities.add(res, this.getItemTotalPriceBeforeTax(item)); + res = NumberUtilities.add(res, this.getItemTotalPriceBeforeTax(item)); } - return res.lt(0) ? BigNumberUtilities.new(0) : res; + return res.lt(0) ? NumberUtilities.new(0) : res; } // eslint-disable-next-line jsdoc/require-jsdoc getTotalAfterTax(invoice: Invoice): BigNumber { const totalBeforeTax: BigNumber = this.getTotalBeforeTax(invoice); const totalTax: BigNumber = this.getTotalTax(invoice); - return BigNumberUtilities.add(totalBeforeTax, totalTax); + return NumberUtilities.add(totalBeforeTax, totalTax); } // eslint-disable-next-line jsdoc/require-jsdoc getTotalTax(invoice: Invoice): BigNumber { - let res: BigNumber = BigNumberUtilities.new(0); + let res: BigNumber = NumberUtilities.new(0); for (const item of invoice.items) { - res = BigNumberUtilities.add(res, this.getItemTotalTax(item)); + res = NumberUtilities.add(res, this.getItemTotalTax(item)); } return res; } @@ -57,9 +57,9 @@ export class InvoiceCalcService implements InvoiceCalcServiceInterface // eslint-disable-next-line jsdoc/require-jsdoc getTotalTaxForTaxGroup(invoice: Invoice, group: Vat, groupOnlyByRate: boolean): BigNumber { const itemsInTaxGroup: InvoiceItem[] = this.getItemsForTaxGroup(invoice, group, groupOnlyByRate); - let res: BigNumber = BigNumberUtilities.new(0); + let res: BigNumber = NumberUtilities.new(0); for (const item of itemsInTaxGroup) { - res = BigNumberUtilities.add(res, this.getItemTotalTax(item)); + res = NumberUtilities.add(res, this.getItemTotalTax(item)); } return res; } @@ -67,11 +67,11 @@ export class InvoiceCalcService implements InvoiceCalcServiceInterface // eslint-disable-next-line jsdoc/require-jsdoc getTotalBeforeTaxForTaxGroup(invoice: Invoice, group: Vat, groupOnlyByRate: boolean): BigNumber { const itemsInTaxGroup: InvoiceItem[] = this.getItemsForTaxGroup(invoice, group, groupOnlyByRate); - let res: BigNumber = BigNumberUtilities.new(0); + let res: BigNumber = NumberUtilities.new(0); for (const item of itemsInTaxGroup) { - res = BigNumberUtilities.add(res, this.getItemTotalPriceBeforeTax(item)); + res = NumberUtilities.add(res, this.getItemTotalPriceBeforeTax(item)); } - return res.lt(0) ? BigNumberUtilities.new(0) : res; + return res.lt(0) ? NumberUtilities.new(0) : res; } private getItemsForTaxGroup(invoice: Invoice, group: Vat, groupOnlyByRate: boolean): InvoiceItem[] { diff --git a/src/plugin/invoicing/services/invoice-pdf.service.ts b/src/plugin/invoicing/services/invoice-pdf.service.ts index 1979238..cb0892c 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/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/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/plugin/payment/providers/pay-pal/pay-pal.payment-provider.test.ts b/src/plugin/payment/providers/pay-pal/pay-pal.payment-provider.test.ts index 91efde1..240be39 100644 --- a/src/plugin/payment/providers/pay-pal/pay-pal.payment-provider.test.ts +++ b/src/plugin/payment/providers/pay-pal/pay-pal.payment-provider.test.ts @@ -13,6 +13,7 @@ import { inject } from '../../../../di/inject.function'; import { defineProvider } from '../../../../di/models/di-provider.model'; import { isHttpClientError } from '../../../../http-client/http-client.error'; import { CurrencyCode } from '../../../../localization/models/currency-code.model'; +import { JsonUtilities } from '../../../../utilities/json.utilities'; import { UUIDUtilities } from '../../../../utilities/uuid.utilities'; import { KnownPaymentMethod, PaymentMethod } from '../../models/payment-method.model'; import { PaymentPluginOptionsInput } from '../../models/payment-plugin-options-input.model'; @@ -49,7 +50,7 @@ async function simulateBuyerApproval(merchantToken: string, orderId: string, ret Authorization: `Bearer ${merchantToken}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ + body: JsonUtilities.stringify({ payment_source: { // eslint-disable-next-line cspell/spellchecker paypal: { diff --git a/src/plugin/payment/providers/pay-pal/pay-pal.payment-provider.ts b/src/plugin/payment/providers/pay-pal/pay-pal.payment-provider.ts index 2ebc167..11a7487 100644 --- a/src/plugin/payment/providers/pay-pal/pay-pal.payment-provider.ts +++ b/src/plugin/payment/providers/pay-pal/pay-pal.payment-provider.ts @@ -217,7 +217,7 @@ export class PayPalPaymentProvider implements PaymentProviderInterface< M extends SupportedMethods[number], Data extends ProviderPaymentDataMap[M] | ProviderReservationPaymentDataMap[M] >(): Repository> { - return inject(repositoryTokenFor(Payment)) as Repository>; + return inject(repositoryTokenFor(Payment)) as unknown as Repository>; } // eslint-disable-next-line jsdoc/require-jsdoc diff --git a/src/preact/collector.ts b/src/preact/collector.ts index 5b7b436..70d63a5 100644 --- a/src/preact/collector.ts +++ b/src/preact/collector.ts @@ -1,11 +1,25 @@ import { ComponentChild, ComponentChildren, Fragment, VNode } from 'preact'; import { stringAwareReplace } from './string-aware-replace.function'; -import { OmitStrict } from '../types/omit-strict.type'; +import { JsonUtilities } from '../utilities/json.utilities'; import { ObjectUtilities } from '../utilities/object.utilities'; const HANDLERS_DIRECTIVE: string = 'data-ssr-handlers'; +/** + * Context of a current loop iteration. + */ +type LoopContext = { + /** + * The name of the currently looped over value. + */ + varName: string, + /** + * The currently looped over value. + */ + value: unknown +}; + /** * A registered event handler entry. */ @@ -22,14 +36,13 @@ type HandlerEntry = { /** * The prefix of the component instance whose rendered native element owns this handler. */ - ownerPrefix: string | undefined + ownerPrefix: string | undefined, + /** + * Context of a current loop iteration that this handler is called from. + */ + loopContext: LoopContext | undefined }; -/** - * A serialized event handler entry. - */ -type SerializedHandlerEntry = OmitStrict; - /** * Result for a simple delegate (basically just a passthrough). */ @@ -118,6 +131,7 @@ export class PreactCollector { private readonly map: Map = new Map(); private readonly nestedComponents: NestedComponentEntry[] = []; private readonly componentCounters: Map = new Map(); + private readonly loopContextMap: WeakMap = new WeakMap(); private readonly handlerRegex: RegExp = /^on/i; /** @@ -149,8 +163,8 @@ export class PreactCollector { } } else if (typeof val === 'function') { - // Non-event function prop — can't be JSON.stringify'd, handle as inline binding - nonHandlerFns.set(key, val as Function); + // Non-event function prop — can't be JsonUtilities.stringify'd, handle as inline binding + nonHandlerFns.set(key, val); } else { // Capture non-handler props (className, type, disabled, etc.) @@ -160,9 +174,31 @@ export class PreactCollector { let rendered: ComponentChild; try { - this.validateSingleParam(node.type as Function); - // eslint-disable-next-line typescript/no-unsafe-assignment, typescript/no-unsafe-call - rendered = (node.type as Function)(node.props); + this.validateSingleParam(node.type); + const originalMap: typeof Array.prototype.map = Array.prototype.map; + // eslint-disable-next-line typescript/no-this-alias + const self: this = this; + Array.prototype.map = function patchedMap( + this: T[], + cb: (value: T, index: number, array: T[]) => U, + thisArg?: unknown + ): U[] { + const varName: string | undefined = self.extractFirstParamName(cb.toString()); + return originalMap.call(this, (item: T, index: number, arr: T[]) => { + const result: U = cb.call(thisArg, item, index, arr); + if (varName && result !== null && typeof result === 'object') { + self.loopContextMap.set(result as object, { varName, value: item }); + } + return result; + }) as U[]; + } as typeof Array.prototype.map; + try { + // eslint-disable-next-line typescript/no-unsafe-assignment, typescript/no-unsafe-call + rendered = (node.type as Function)(node.props); + } + finally { + Array.prototype.map = originalMap; + } if (rendered instanceof Promise) { throw new Error( `[ssr] Nested component '${node.type.name}' is async. ` @@ -207,7 +243,7 @@ export class PreactCollector { } } if (ObjectUtilities.keys(resolvedProps).length) { - componentBindings[`__propsObj_${propsParamName}`] = JSON.stringify(resolvedProps); + componentBindings[`__propsObj_${propsParamName}`] = JsonUtilities.stringify(resolvedProps); } } } @@ -239,7 +275,8 @@ export class PreactCollector { const val: unknown = node.props[key]; if (this.handlerRegex.test(key) && typeof val === 'function') { const event: string = key.slice(2).toLowerCase(); - const id: string = this.registerHandler(val, event, parentPrefix); + const loopContext: LoopContext | undefined = this.loopContextMap.get(node) ?? undefined; + const id: string = this.registerHandler(val, event, parentPrefix, loopContext); handlers.push(`${id}:${event}`); } } @@ -336,7 +373,10 @@ export class PreactCollector { } let src: string = entry.src; for (const [from, to] of sorted) { - // Pass 1 — expand shorthand destructure properties + if (from === entry.loopContext?.varName) { + continue; + } + // Pass 1 — expand shorthand destructure properties src = stringAwareReplace( src, new RegExp(`(?<=[{,]\\s*)${from}(?=\\s*[,}])`, 'g'), @@ -350,7 +390,7 @@ export class PreactCollector { ); } if (src !== entry.src) { - this.map.set(id, { event: entry.event, src, ownerPrefix: entry.ownerPrefix }); + this.map.set(id, { src, event: entry.event, ownerPrefix: entry.ownerPrefix, loopContext: entry.loopContext }); } } } @@ -384,36 +424,31 @@ 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>'); + 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 = ${json};`, + ' const map = {', + entries.join(',\n'), + ' };', ' 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; }', @@ -442,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; } @@ -671,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 1c8db67..f5f2e80 100644 --- a/src/preact/preact.utilities.ts +++ b/src/preact/preact.utilities.ts @@ -7,8 +7,13 @@ 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 { JsonUtilities } from '../utilities/json.utilities'; import { ObjectUtilities } from '../utilities/object.utilities'; /** @@ -230,43 +235,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