From bcd44b0eb28bc53c8c5b384df30ccac34db51782 Mon Sep 17 00:00:00 2001 From: Ved-viraj Date: Mon, 22 Jun 2026 17:18:16 +0100 Subject: [PATCH 1/2] feat(listener): add immutable audit trail for notification template updates Persist template changes with actor and timestamp in an append-only audit log, expose admin API routes, and wire updates through NotificationTemplateRepository. --- listener/.eslintignore | 8 + listener/.eslintrc.cjs | 17 + listener/package-lock.json | 1539 ++++++++++++++--- listener/package.json | 5 + listener/src/api/events-server.ts | 203 ++- listener/src/api/template-api.ts | 92 + listener/src/api/templates-api.test.ts | 202 +++ listener/src/database/database.ts | 1 - listener/src/database/schema.sql | 57 + listener/src/index.ts | 60 +- .../src/services/notification-scheduler.ts | 2 +- .../services/notification-template-cache.ts | 16 +- .../notification-template-repository.test.ts | 235 +++ .../notification-template-repository.ts | 202 +++ .../notification-template-service.test.ts | 46 + .../services/notification-template-service.ts | 41 + listener/src/services/template-audit-trail.ts | 85 + listener/src/types/notification-template.ts | 64 + listener/src/utils/request-actor.ts | 31 + 19 files changed, 2643 insertions(+), 263 deletions(-) create mode 100644 listener/.eslintignore create mode 100644 listener/.eslintrc.cjs create mode 100644 listener/src/api/template-api.ts create mode 100644 listener/src/api/templates-api.test.ts create mode 100644 listener/src/services/notification-template-repository.test.ts create mode 100644 listener/src/services/notification-template-repository.ts create mode 100644 listener/src/services/notification-template-service.test.ts create mode 100644 listener/src/services/notification-template-service.ts create mode 100644 listener/src/services/template-audit-trail.ts create mode 100644 listener/src/types/notification-template.ts create mode 100644 listener/src/utils/request-actor.ts diff --git a/listener/.eslintignore b/listener/.eslintignore new file mode 100644 index 0000000..19319b8 --- /dev/null +++ b/listener/.eslintignore @@ -0,0 +1,8 @@ +node_modules +dist +coverage +*.log +**/*.test.ts +src/__tests__ +src/examples +src/test-utils diff --git a/listener/.eslintrc.cjs b/listener/.eslintrc.cjs new file mode 100644 index 0000000..f528b4d --- /dev/null +++ b/listener/.eslintrc.cjs @@ -0,0 +1,17 @@ +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + }, + plugins: ['@typescript-eslint'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + ], + env: { node: true, es2021: true, jest: true }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + }, +}; diff --git a/listener/package-lock.json b/listener/package-lock.json index 21d29fc..0389039 100644 --- a/listener/package-lock.json +++ b/listener/package-lock.json @@ -23,6 +23,9 @@ "@types/node": "^25.9.3", "@types/node-cache": "^4.1.3", "@types/uuid": "^9.0.8", + "@typescript-eslint/eslint-plugin": "^6.10.0", + "@typescript-eslint/parser": "^6.10.0", + "eslint": "^8.46.0", "jest": "^29.7.0", "ts-jest": "^29.4.11", "ts-node": "^10.9.2", @@ -60,7 +63,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -570,6 +572,99 @@ "kuler": "^2.0.0" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -577,6 +672,44 @@ "license": "MIT", "optional": true }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1104,6 +1237,44 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@npmcli/fs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", @@ -1330,9 +1501,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1350,9 +1518,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1370,9 +1535,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1390,9 +1552,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1410,9 +1569,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1430,9 +1586,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1659,13 +1812,19 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.9.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -1680,6 +1839,13 @@ "@types/node": "*" } }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -1717,158 +1883,455 @@ "dev": true, "license": "MIT" }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "license": "ISC", - "optional": true - }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" }, "engines": { - "node": ">=0.4.0" + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=0.4.0" + "node": ">=10" } }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "optional": true, + "node_modules/@typescript-eslint/parser": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "debug": "4" + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4" }, "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "humanize-ms": "^1.2.1" + "node": "^16.0.0 || >=18.0.0" }, - "engines": { - "node": ">= 8.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" }, "engines": { - "node": ">=8" + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "node_modules/@typescript-eslint/type-utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", "dev": true, "license": "MIT", "dependencies": { - "type-fest": "^0.21.3" + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" }, "engines": { - "node": ">=8" + "node": "^16.0.0 || >=18.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "devOptional": true, + "node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "color-convert": "^2.0.1" + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" }, "engines": { - "node": ">=8" + "node": "^16.0.0 || >=18.0.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/ansi-styles/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" + "balanced-match": "^1.0.0" } }, - "node_modules/ansi-styles/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", "dev": true, "license": "ISC", "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">= 8" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/aproba": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansi-styles/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/ansi-styles/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", "license": "ISC", "optional": true @@ -1905,6 +2368,16 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2205,7 +2678,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2722,6 +3194,13 @@ "node": ">=4.0.0" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -2804,6 +3283,32 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -2974,6 +3479,203 @@ "node": ">=8" } }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -2988,6 +3690,52 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/eventsource": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", @@ -3056,6 +3804,43 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3063,6 +3848,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -3088,6 +3890,19 @@ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "license": "MIT" }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -3121,6 +3936,28 @@ "node": ">=8" } }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/fn.name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", @@ -3356,6 +4193,69 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globals/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3375,6 +4275,13 @@ "devOptional": true, "license": "ISC" }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/handlebars": { "version": "4.7.9", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", @@ -3541,29 +4448,6 @@ "node": ">=0.10.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "ms": "^2.0.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -3584,6 +4468,43 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -3700,6 +4621,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -3720,6 +4651,19 @@ "node": ">=6" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-lambda": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", @@ -3737,6 +4681,16 @@ "node": ">=0.12.0" } }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-retry-allowed": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-3.0.0.tgz", @@ -3879,7 +4833,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -4504,6 +5457,13 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -4511,6 +5471,20 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4531,6 +5505,16 @@ "dev": true, "license": "MIT" }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -4557,6 +5541,20 @@ "node": ">=6" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -4584,6 +5582,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/logform": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", @@ -4721,6 +5726,16 @@ "dev": true, "license": "MIT" }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5041,74 +6056,6 @@ "node": ">=10" } }, - "node_modules/node-abi": { - "version": "3.92.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", - "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-abi/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "license": "MIT" - }, - "node_modules/node-gyp": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", - "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", - "license": "MIT", - "optional": true, - "dependencies": { - "env-paths": "^2.2.0", - "glob": "^7.1.4", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^9.1.0", - "nopt": "^5.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^2.0.2" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": ">= 10.12.0" - } - }, - "node_modules/node-gyp/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -5216,6 +6163,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -5287,6 +6252,19 @@ "node": ">=6" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -5343,6 +6321,16 @@ "dev": true, "license": "MIT" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5422,6 +6410,16 @@ "node": ">=10" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -5504,6 +6502,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -5521,6 +6529,27 @@ ], "license": "MIT" }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -5650,13 +6679,24 @@ "node": ">= 4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", + "devOptional": true, "license": "ISC", - "optional": true, "dependencies": { "glob": "^7.1.3" }, @@ -5667,6 +6707,30 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -6175,6 +7239,13 @@ "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", "license": "MIT" }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -6224,6 +7295,19 @@ "node": ">= 14.0.0" } }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, "node_modules/ts-jest": { "version": "29.4.11", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", @@ -6309,7 +7393,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -6360,6 +7443,19 @@ "node": "*" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -6403,7 +7499,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6484,6 +7579,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/urijs": { "version": "1.19.11", "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", @@ -6636,6 +7741,16 @@ "node": ">= 12.0.0" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", diff --git a/listener/package.json b/listener/package.json index 26360a9..78d46d9 100644 --- a/listener/package.json +++ b/listener/package.json @@ -7,6 +7,8 @@ "build": "node ./node_modules/typescript/bin/tsc", "start": "node dist/index.js", "test": "node ./node_modules/jest/bin/jest.js", + "lint": "node ./node_modules/eslint/bin/eslint.js 'src/**/*.ts' --max-warnings=0", + "typecheck": "node ./node_modules/typescript/bin/tsc --noEmit", "migrate": "ts-node src/scripts/migrate-db.ts", "validate:batch": "ts-node src/utils/batch-validator.ts" }, @@ -23,6 +25,9 @@ "winston": "^3.19.0" }, "devDependencies": { + "@typescript-eslint/eslint-plugin": "^6.10.0", + "@typescript-eslint/parser": "^6.10.0", + "eslint": "^8.46.0", "@swc/core": "^1.15.41", "@swc/jest": "^0.2.39", "@types/jest": "^29.5.14", diff --git a/listener/src/api/events-server.ts b/listener/src/api/events-server.ts index 197e189..19dfc27 100644 --- a/listener/src/api/events-server.ts +++ b/listener/src/api/events-server.ts @@ -19,9 +19,20 @@ import { WebhookSecret, RateLimitConfig } from '../types'; import { RateLimiter } from './rate-limiter'; import { getNotificationAnalyticsAggregator, - setNotificationAnalyticsAggregator, NotificationAnalyticsAggregator, } from '../services/notification-analytics-aggregator'; +import { NotificationTemplateService } from '../services/notification-template-service'; +import { + TemplateNotFoundError, + TemplateValidationError, +} from '../services/notification-template-repository'; +import { + parseTemplateUpdateBody, + resolveRequestActor, + serializeAuditRecord, + serializeTemplate, +} from './template-api'; +import { CreateNotificationTemplateInput } from '../types/notification-template'; export interface EventsServerOptions { port: number; @@ -37,6 +48,7 @@ export interface EventsServerOptions { * process-wide default aggregator is used. */ analyticsAggregator?: NotificationAnalyticsAggregator | null; + templateService?: NotificationTemplateService | null; } type ServiceStatus = 'ok' | 'error' | 'not_configured'; @@ -140,6 +152,10 @@ async function buildHealthResponse(options: EventsServerOptions): Promise { @@ -473,18 +489,180 @@ export function createEventsServer(options: EventsServerOptions): http.Server { return; } - logger.warn('Unhandled request', { - requestId, - method: req.method, - url: req.url, - }); + // GET /api/templates/:id/audit + const templateAuditMatch = url.pathname.match(/^\/api\/templates\/([^/]+)\/audit$/); + if (req.method === 'GET' && templateAuditMatch) { + if (!options.templateService) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Template service not enabled' })); + return; + } + + const templateId = decodeURIComponent(templateAuditMatch[1]); + logger.info('Handling GET /api/templates/:id/audit', { requestId, correlationId, templateId }); + + options.templateService.getAuditHistory(templateId) + .then(async (records) => { + const template = await options.templateService!.getById(templateId); + if (!template && records.length === 0) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Template not found' })); + return; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + templateId, + records: records.map(serializeAuditRecord), + })); + }) + .catch((error) => { + logger.error('Failed to load template audit history', { error, requestId, correlationId, templateId }); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + }); + return; + } + + // GET /api/templates/:id + const getTemplateMatch = url.pathname.match(/^\/api\/templates\/([^/]+)$/); + if (req.method === 'GET' && getTemplateMatch) { + if (!options.templateService) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Template service not enabled' })); + return; + } + + const templateId = decodeURIComponent(getTemplateMatch[1]); + logger.info('Handling GET /api/templates/:id', { requestId, correlationId, templateId }); + + options.templateService.getById(templateId) + .then((template) => { + if (!template) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Template not found' })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(serializeTemplate(template))); + }) + .catch((error) => { + logger.error('Failed to load template', { error, requestId, correlationId, templateId }); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + }); + return; + } + + // PUT /api/templates/:id + if (req.method === 'PUT' && getTemplateMatch) { + if (!options.templateService) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Template service not enabled' })); + return; + } + + const templateId = decodeURIComponent(getTemplateMatch[1]); + const actor = resolveRequestActor(req); + logger.info('Handling PUT /api/templates/:id', { requestId, correlationId, templateId, actor }); + + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + void (async () => { + try { + const parsed = JSON.parse(body) as unknown; + const input = parseTemplateUpdateBody(parsed); + const updated = await options.templateService!.update(templateId, input, actor); + logger.info('PUT /api/templates/:id complete', { + requestId, + correlationId, + templateId, + actor, + durationMs: Date.now() - startTime, + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(serializeTemplate(updated))); + } catch (error) { + if (error instanceof SyntaxError) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (error instanceof TemplateNotFoundError) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: error.message })); + return; + } + if (error instanceof TemplateValidationError || (error instanceof Error && error.message.startsWith('Invalid body'))) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + return; + } + logger.error('Failed to update template', { error, requestId, correlationId, templateId, actor }); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + } + })(); + }); + return; + } + + // POST /api/templates + if (req.method === 'POST' && url.pathname === '/api/templates') { + if (!options.templateService) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Template service not enabled' })); + return; + } + + logger.info('Handling POST /api/templates', { requestId, correlationId }); + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + void (async () => { + try { + const parsed = JSON.parse(body) as CreateNotificationTemplateInput; + if (!parsed?.id || !parsed?.name || !parsed?.type || !parsed?.body) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: 'Invalid body: id, name, type, and body are required', + })); + return; + } + + const created = await options.templateService!.create(parsed); + res.writeHead(201, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(serializeTemplate(created))); + } catch (error) { + if (error instanceof SyntaxError) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (error instanceof TemplateValidationError) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: error.message })); + return; + } + logger.error('Failed to create template', { error, requestId, correlationId }); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + } + })(); + }); + return; + } + // GET /api/preferences/:userId const getPrefsMatch = url.pathname.match(/^\/api\/preferences\/([^/]+)$/); if (req.method === 'GET' && getPrefsMatch) { const userId = decodeURIComponent(getPrefsMatch[1]); logger.info('Handling GET /api/preferences/:userId', { requestId, correlationId, userId }); const prefs = preferenceStore.get(userId); + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(prefs)); + return; } // PUT /api/preferences/:userId @@ -515,7 +693,12 @@ export function createEventsServer(options: EventsServerOptions): http.Server { }); return; } - logger.warn('Unhandled request', { requestId, correlationId, method: req.method, url: req.url }); + + logger.warn('Unhandled request', { + requestId, + method: req.method, + url: req.url, + }); res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); }); diff --git a/listener/src/api/template-api.ts b/listener/src/api/template-api.ts new file mode 100644 index 0000000..9fb3309 --- /dev/null +++ b/listener/src/api/template-api.ts @@ -0,0 +1,92 @@ +import { resolveRequestActor } from '../utils/request-actor'; +import { + NotificationTemplate, + TemplateAuditRecord, + UpdateNotificationTemplateInput, +} from '../types/notification-template'; + +export function serializeTemplate(template: NotificationTemplate): Record { + return { + ...template, + createdAt: template.createdAt.toISOString(), + updatedAt: template.updatedAt.toISOString(), + }; +} + +export function serializeAuditRecord(record: TemplateAuditRecord): Record { + return { + id: record.id, + templateId: record.templateId, + actor: record.actor, + action: record.action, + changedAt: record.changedAt.toISOString(), + previousSnapshot: serializeTemplate(normalizeSnapshot(record.previousSnapshot)), + newSnapshot: serializeTemplate(normalizeSnapshot(record.newSnapshot)), + }; +} + +function normalizeSnapshot(snapshot: NotificationTemplate): NotificationTemplate { + return { + ...snapshot, + createdAt: snapshot.createdAt instanceof Date + ? snapshot.createdAt + : new Date(snapshot.createdAt as unknown as string), + updatedAt: snapshot.updatedAt instanceof Date + ? snapshot.updatedAt + : new Date(snapshot.updatedAt as unknown as string), + }; +} + +export function parseTemplateUpdateBody(body: unknown): UpdateNotificationTemplateInput { + if (!body || typeof body !== 'object') { + throw new Error('Invalid body: expected a template update object'); + } + + const input = body as Record; + const update: UpdateNotificationTemplateInput = {}; + + if ('name' in input) { + if (typeof input.name !== 'string') { + throw new Error('Invalid body: name must be a string'); + } + update.name = input.name; + } + if ('type' in input) { + if (typeof input.type !== 'string') { + throw new Error('Invalid body: type must be a string'); + } + update.type = input.type; + } + if ('subject' in input) { + if (input.subject !== undefined && input.subject !== null && typeof input.subject !== 'string') { + throw new Error('Invalid body: subject must be a string'); + } + update.subject = input.subject as string | undefined; + } + if ('body' in input) { + if (typeof input.body !== 'string') { + throw new Error('Invalid body: body must be a string'); + } + update.body = input.body; + } + if ('variables' in input) { + if (!Array.isArray(input.variables) || input.variables.some((v) => typeof v !== 'string')) { + throw new Error('Invalid body: variables must be an array of strings'); + } + update.variables = input.variables; + } + if ('metadata' in input) { + if (input.metadata !== null && (typeof input.metadata !== 'object' || Array.isArray(input.metadata))) { + throw new Error('Invalid body: metadata must be an object'); + } + update.metadata = input.metadata as Record; + } + + if (Object.keys(update).length === 0) { + throw new Error('Invalid body: at least one template field must be provided'); + } + + return update; +} + +export { resolveRequestActor }; diff --git a/listener/src/api/templates-api.test.ts b/listener/src/api/templates-api.test.ts new file mode 100644 index 0000000..07d1843 --- /dev/null +++ b/listener/src/api/templates-api.test.ts @@ -0,0 +1,202 @@ +import http from 'http'; +import { createEventsServer } from './events-server'; +import { Database } from '../database/database'; +import { NotificationTemplateRepository } from '../services/notification-template-repository'; +import { NotificationTemplateService } from '../services/notification-template-service'; +import { TemplateAuditTrail } from '../services/template-audit-trail'; +import { NotificationTemplateCache } from '../services/notification-template-cache'; +import { parseTemplateUpdateBody } from './template-api'; +import { resolveRequestActor } from '../utils/request-actor'; + +jest.mock('@stellar/stellar-sdk', () => ({ + rpc: { + Server: jest.fn().mockImplementation(() => ({ + getHealth: jest.fn().mockResolvedValue({ status: 'healthy' }), + })), + }, +})); + +jest.mock('../utils/logger', () => ({ + __esModule: true, + default: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() }, +})); + +function request( + server: http.Server, + method: string, + path: string, + options?: { + body?: object; + headers?: Record; + }, +): Promise<{ status: number; body: unknown }> { + return new Promise((resolve, reject) => { + const port = (server.address() as { port: number }).port; + const payload = options?.body ? JSON.stringify(options.body) : undefined; + const req = http.request( + { + hostname: '127.0.0.1', + port, + path, + method, + headers: { + 'Content-Type': 'application/json', + ...(options?.headers ?? {}), + ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}), + }, + }, + (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => resolve({ + status: res.statusCode!, + body: data ? JSON.parse(data) : null, + })); + }, + ); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); +} + +async function createTemplateService(): Promise<{ + db: Database; + service: NotificationTemplateService; +}> { + const db = new Database(':memory:'); + await db.initialize(); + const cache = new NotificationTemplateCache(60, 0); + const repository = new NotificationTemplateRepository(db, new TemplateAuditTrail(db), cache); + const service = new NotificationTemplateService(repository, cache); + return { db, service }; +} + +describe('Template API endpoints', () => { + let db: Database; + let service: NotificationTemplateService; + let server: http.Server; + + beforeEach(async () => { + ({ db, service } = await createTemplateService()); + server = createEventsServer({ + port: 0, + stellarRpcUrl: 'http://localhost', + templateService: service, + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + await service.create({ + id: 'welcome-email', + name: 'Welcome Email', + type: 'email', + subject: 'Welcome', + body: 'Hello {{name}}', + variables: ['name'], + }); + }); + + afterEach(async () => { + await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); + await db.close(); + }); + + it('PUT /api/templates/:id updates via repository and records actor from x-api-key', async () => { + const res = await request(server, 'PUT', '/api/templates/welcome-email', { + headers: { 'x-api-key': 'admin-key-123' }, + body: { body: 'Hello {{name}}, welcome aboard!' }, + }); + + expect(res.status).toBe(200); + expect((res.body as { body: string }).body).toBe('Hello {{name}}, welcome aboard!'); + + const audit = await service.getAuditHistory('welcome-email'); + expect(audit).toHaveLength(1); + expect(audit[0].actor).toBe('api-key:admin-key-123'); + }); + + it('GET /api/templates/:id/audit returns update history', async () => { + await request(server, 'PUT', '/api/templates/welcome-email', { + headers: { Authorization: 'Bearer editor-token' }, + body: { name: 'Welcome Email v2' }, + }); + + const res = await request(server, 'GET', '/api/templates/welcome-email/audit'); + + expect(res.status).toBe(200); + const body = res.body as { templateId: string; records: Array<{ actor: string; action: string }> }; + expect(body.templateId).toBe('welcome-email'); + expect(body.records).toHaveLength(1); + expect(body.records[0].actor).toBe('bearer:editor-token'); + expect(body.records[0].action).toBe('UPDATE'); + }); + + it('GET /api/templates/:id returns a template through the cache-backed service', async () => { + const res = await request(server, 'GET', '/api/templates/welcome-email'); + expect(res.status).toBe(200); + expect((res.body as { id: string }).id).toBe('welcome-email'); + }); + + it('POST /api/templates creates a template', async () => { + const res = await request(server, 'POST', '/api/templates', { + body: { + id: 'digest', + name: 'Daily Digest', + type: 'email', + body: 'Your daily summary', + }, + }); + + expect(res.status).toBe(201); + expect((res.body as { id: string }).id).toBe('digest'); + }); + + it('returns 404 when updating a missing template', async () => { + const res = await request(server, 'PUT', '/api/templates/missing', { + headers: { 'x-api-key': 'admin' }, + body: { body: 'Nope' }, + }); + expect(res.status).toBe(404); + }); + + it('returns 404 for audit history on a missing template', async () => { + const res = await request(server, 'GET', '/api/templates/missing/audit'); + expect(res.status).toBe(404); + }); + + it('returns 400 for an empty update body', async () => { + const res = await request(server, 'PUT', '/api/templates/welcome-email', { + headers: { 'x-api-key': 'admin' }, + body: {}, + }); + expect(res.status).toBe(400); + }); + + it('returns 503 when template service is not configured', async () => { + const disabledServer = createEventsServer({ + port: 0, + stellarRpcUrl: 'http://localhost', + }); + await new Promise((resolve) => disabledServer.listen(0, '127.0.0.1', () => resolve())); + + const res = await request(disabledServer, 'PUT', '/api/templates/welcome-email', { + body: { body: 'Blocked' }, + }); + + expect(res.status).toBe(503); + await new Promise((resolve, reject) => disabledServer.close((err) => (err ? reject(err) : resolve()))); + }); +}); + +describe('template-api helpers', () => { + it('parseTemplateUpdateBody rejects empty updates', () => { + expect(() => parseTemplateUpdateBody({})).toThrow('at least one template field'); + }); + + it('resolveRequestActor prefers API key identity', () => { + const actor = resolveRequestActor({ + headers: { 'x-api-key': 'secret' }, + socket: { remoteAddress: '127.0.0.1' }, + } as unknown as http.IncomingMessage); + expect(actor).toBe('api-key:secret'); + }); +}); diff --git a/listener/src/database/database.ts b/listener/src/database/database.ts index 6ae3b94..4707caa 100644 --- a/listener/src/database/database.ts +++ b/listener/src/database/database.ts @@ -1,5 +1,4 @@ import * as sqlite3 from 'sqlite3'; -import { promisify } from 'util'; import * as fs from 'fs'; import * as path from 'path'; import logger from '../utils/logger'; diff --git a/listener/src/database/schema.sql b/listener/src/database/schema.sql index 4591ca0..632007c 100644 --- a/listener/src/database/schema.sql +++ b/listener/src/database/schema.sql @@ -110,3 +110,60 @@ CREATE INDEX IF NOT EXISTS idx_rate_limit_events_timestamp CREATE INDEX IF NOT EXISTS idx_rate_limit_events_client_id ON rate_limit_events(client_id); +-- Notification templates +CREATE TABLE IF NOT EXISTS notification_templates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + subject TEXT, + body TEXT NOT NULL, + variables TEXT, + metadata TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_notification_templates_type + ON notification_templates(type); + +CREATE TRIGGER IF NOT EXISTS update_notification_templates_timestamp +AFTER UPDATE ON notification_templates +FOR EACH ROW +BEGIN + UPDATE notification_templates + SET updated_at = CURRENT_TIMESTAMP + WHERE id = NEW.id; +END; + +-- Immutable audit trail for template modifications +CREATE TABLE IF NOT EXISTS notification_template_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + template_id TEXT NOT NULL, + actor TEXT NOT NULL, + action TEXT NOT NULL DEFAULT 'UPDATE', + changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + previous_snapshot TEXT NOT NULL, + new_snapshot TEXT NOT NULL, + FOREIGN KEY (template_id) REFERENCES notification_templates(id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_template_audit_template_id + ON notification_template_audit_log(template_id); + +CREATE INDEX IF NOT EXISTS idx_template_audit_changed_at + ON notification_template_audit_log(changed_at); + +CREATE TRIGGER IF NOT EXISTS prevent_template_audit_update +BEFORE UPDATE ON notification_template_audit_log +FOR EACH ROW +BEGIN + SELECT RAISE(ABORT, 'Audit records are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS prevent_template_audit_delete +BEFORE DELETE ON notification_template_audit_log +FOR EACH ROW +BEGIN + SELECT RAISE(ABORT, 'Audit records are immutable'); +END; + diff --git a/listener/src/index.ts b/listener/src/index.ts index acc8679..1453e41 100644 --- a/listener/src/index.ts +++ b/listener/src/index.ts @@ -3,6 +3,10 @@ import { startEventsServer } from './api/events-server'; import { EventSubscriber } from './services/event-subscriber'; import { NotificationScheduler } from './services/notification-scheduler'; import { ScheduledNotificationRepository } from './services/scheduled-notification-repository'; +import { NotificationTemplateRepository } from './services/notification-template-repository'; +import { NotificationTemplateService } from './services/notification-template-service'; +import { TemplateAuditTrail } from './services/template-audit-trail'; +import { getTemplateCache } from './services/notification-template-cache'; import { NotificationAPI } from './services/notification-api'; import { initializeDatabase } from './database/database'; import { DiscordNotificationService } from './services/discord-notification'; @@ -14,35 +18,40 @@ dotenv.config(); async function main() { const config = loadConfig(); - // Initialize database if scheduler or rate limiting is enabled + // Initialize database for templates, scheduler, and rate limiting let scheduler: NotificationScheduler | null = null; let notificationAPI: NotificationAPI | null = null; - const needDb = config.scheduler?.enabled || config.rateLimit?.enabled; - - if (needDb) { - try { - logger.info('Initializing database'); - const db = await initializeDatabase(config.databasePath); - - if (config.scheduler?.enabled) { - const repository = new ScheduledNotificationRepository(db); - notificationAPI = new NotificationAPI(repository); - - // Initialize scheduler with Discord service if available - let discordService: DiscordNotificationService | null = null; - if (config.discord) { - discordService = new DiscordNotificationService(config.discord); - } + let templateService: NotificationTemplateService | null = null; + + try { + logger.info('Initializing database'); + const db = await initializeDatabase(config.databasePath); + + const templateRepository = new NotificationTemplateRepository( + db, + new TemplateAuditTrail(db), + getTemplateCache(), + ); + templateService = new NotificationTemplateService(templateRepository); + + if (config.scheduler?.enabled) { + const repository = new ScheduledNotificationRepository(db); + notificationAPI = new NotificationAPI(repository); + + // Initialize scheduler with Discord service if available + let discordService: DiscordNotificationService | null = null; + if (config.discord) { + discordService = new DiscordNotificationService(config.discord); + } - scheduler = new NotificationScheduler(repository, config.scheduler, discordService); - await scheduler.start(); + scheduler = new NotificationScheduler(repository, config.scheduler, discordService); + await scheduler.start(); - logger.info('Notification scheduler started successfully'); - } - } catch (error) { - logger.error('Failed to initialize database or scheduler', { error }); - throw error; + logger.info('Notification scheduler started successfully'); } + } catch (error) { + logger.error('Failed to initialize database or scheduler', { error }); + throw error; } // Start events server and subscriber @@ -51,7 +60,8 @@ async function main() { corsOrigin: config.eventsApiCorsOrigin, stellarRpcUrl: config.stellarRpcUrl, discordWebhookUrl: config.discord?.webhookUrl, - notificationAPI, // Pass API to events server for scheduling endpoints + notificationAPI, + templateService, rateLimit: config.rateLimit, }); diff --git a/listener/src/services/notification-scheduler.ts b/listener/src/services/notification-scheduler.ts index 4b36704..5afe448 100644 --- a/listener/src/services/notification-scheduler.ts +++ b/listener/src/services/notification-scheduler.ts @@ -2,7 +2,7 @@ import { v4 as uuidv4 } from 'uuid'; import logger from '../utils/logger'; import { generateRequestId } from '../utils/request-id'; import { ScheduledNotificationRepository } from './scheduled-notification-repository'; -import { SchedulerConfig, NotificationStatus, ScheduledNotification } from '../types/scheduled-notification'; +import { SchedulerConfig, ScheduledNotification } from '../types/scheduled-notification'; import { DiscordNotificationService } from './discord-notification'; /** diff --git a/listener/src/services/notification-template-cache.ts b/listener/src/services/notification-template-cache.ts index 3d45cec..0342a72 100644 --- a/listener/src/services/notification-template-cache.ts +++ b/listener/src/services/notification-template-cache.ts @@ -1,20 +1,8 @@ import NodeCache from 'node-cache'; import logger from '../utils/logger'; +import { NotificationTemplate } from '../types/notification-template'; -/** - * Notification template structure - */ -export interface NotificationTemplate { - id: string; - name: string; - type: string; - subject?: string; - body: string; - variables?: string[]; - metadata?: Record; - createdAt: Date; - updatedAt: Date; -} +export type { NotificationTemplate } from '../types/notification-template'; /** * Cache statistics for monitoring hit rate diff --git a/listener/src/services/notification-template-repository.test.ts b/listener/src/services/notification-template-repository.test.ts new file mode 100644 index 0000000..5c747f0 --- /dev/null +++ b/listener/src/services/notification-template-repository.test.ts @@ -0,0 +1,235 @@ +import { Database } from '../database/database'; +import { NotificationTemplateCache } from './notification-template-cache'; +import { + NotificationTemplateRepository, + TemplateNotFoundError, + TemplateValidationError, +} from './notification-template-repository'; +import { TemplateAuditTrail } from './template-audit-trail'; +import { CreateNotificationTemplateInput } from '../types/notification-template'; + +const baseInput = (): CreateNotificationTemplateInput => ({ + id: 'tmpl-001', + name: 'Welcome Email', + type: 'email', + subject: 'Welcome', + body: 'Hello {{name}}', + variables: ['name'], + metadata: { category: 'onboarding' }, +}); + +async function createRepository(): Promise<{ + db: Database; + repository: NotificationTemplateRepository; + auditTrail: TemplateAuditTrail; +}> { + const db = new Database(':memory:'); + await db.initialize(); + const auditTrail = new TemplateAuditTrail(db); + const repository = new NotificationTemplateRepository(db, auditTrail); + return { db, repository, auditTrail }; +} + +describe('NotificationTemplateRepository audit trail', () => { + describe('create', () => { + let db: Database; + let repository: NotificationTemplateRepository; + + beforeEach(async () => { + ({ db, repository } = await createRepository()); + }); + + afterEach(async () => { + await db.close(); + }); + + it('creates a template without audit records', async () => { + const template = await repository.create(baseInput()); + + expect(template.id).toBe('tmpl-001'); + expect(template.name).toBe('Welcome Email'); + expect(await repository.getUpdateHistory('tmpl-001')).toEqual([]); + }); + + it('rejects empty template name', async () => { + await expect( + repository.create({ ...baseInput(), name: ' ' }), + ).rejects.toThrow(TemplateValidationError); + }); + + it('rejects empty template body', async () => { + await expect( + repository.create({ ...baseInput(), body: '' }), + ).rejects.toThrow(TemplateValidationError); + }); + }); + + describe('update', () => { + let db: Database; + let repository: NotificationTemplateRepository; + + beforeEach(async () => { + ({ db, repository } = await createRepository()); + await repository.create(baseInput()); + }); + + afterEach(async () => { + await db.close(); + }); + + it('records update history with actor and timestamp', async () => { + const before = Date.now(); + const updated = await repository.update( + 'tmpl-001', + { body: 'Hello {{name}}, welcome aboard!' }, + 'admin@example.com', + ); + const after = Date.now(); + + expect(updated.body).toBe('Hello {{name}}, welcome aboard!'); + + const history = await repository.getUpdateHistory('tmpl-001'); + expect(history).toHaveLength(1); + expect(history[0].actor).toBe('admin@example.com'); + expect(history[0].action).toBe('UPDATE'); + expect(history[0].changedAt.getTime()).toBeGreaterThanOrEqual(before); + expect(history[0].changedAt.getTime()).toBeLessThanOrEqual(after); + expect(history[0].previousSnapshot.body).toBe('Hello {{name}}'); + expect(history[0].newSnapshot.body).toBe('Hello {{name}}, welcome aboard!'); + }); + + it('stores multiple immutable audit records for successive updates', async () => { + await repository.update('tmpl-001', { name: 'Welcome Email v2' }, 'editor-1'); + await repository.update('tmpl-001', { subject: 'Welcome aboard' }, 'editor-2'); + + const history = await repository.getUpdateHistory('tmpl-001'); + expect(history).toHaveLength(2); + expect(history[0].actor).toBe('editor-1'); + expect(history[1].actor).toBe('editor-2'); + expect(history[0].newSnapshot.name).toBe('Welcome Email v2'); + expect(history[1].newSnapshot.subject).toBe('Welcome aboard'); + }); + + it('requires a non-empty actor', async () => { + await expect( + repository.update('tmpl-001', { body: 'Updated body' }, ' '), + ).rejects.toThrow(TemplateValidationError); + }); + + it('throws when template does not exist', async () => { + await expect( + repository.update('missing-template', { body: 'Nope' }, 'admin@example.com'), + ).rejects.toThrow(TemplateNotFoundError); + }); + + it('does not write audit history when update makes no changes', async () => { + const unchanged = await repository.update( + 'tmpl-001', + { body: 'Hello {{name}}' }, + 'admin@example.com', + ); + + expect(unchanged.body).toBe('Hello {{name}}'); + expect(await repository.getUpdateHistory('tmpl-001')).toEqual([]); + }); + + it('invalidates cached templates after update', async () => { + const cache = new NotificationTemplateCache(60, 0); + const auditTrail = new TemplateAuditTrail(db); + const cachedRepository = new NotificationTemplateRepository(db, auditTrail, cache); + const template = await cachedRepository.getById('tmpl-001'); + cache.set('tmpl-001', template!); + expect(cache.has('tmpl-001')).toBe(true); + + await cachedRepository.update('tmpl-001', { body: 'Cached invalidation test' }, 'cache-admin'); + + expect(cache.has('tmpl-001')).toBe(false); + }); + }); + + describe('audit immutability', () => { + let db: Database; + let repository: NotificationTemplateRepository; + + beforeEach(async () => { + ({ db, repository } = await createRepository()); + await repository.create(baseInput()); + await repository.update('tmpl-001', { body: 'Updated once' }, 'auditor'); + }); + + afterEach(async () => { + await db.close(); + }); + + it('rejects updates to audit records', async () => { + const [record] = await repository.getUpdateHistory('tmpl-001'); + + await expect( + db.run('UPDATE notification_template_audit_log SET actor = ? WHERE id = ?', [ + 'tampered', + record.id, + ]), + ).rejects.toThrow(/immutable/i); + }); + + it('rejects deletes of audit records', async () => { + const [record] = await repository.getUpdateHistory('tmpl-001'); + + await expect( + db.run('DELETE FROM notification_template_audit_log WHERE id = ?', [record.id]), + ).rejects.toThrow(/immutable/i); + }); + }); +}); + +describe('TemplateAuditTrail', () => { + let db: Database; + let auditTrail: TemplateAuditTrail; + + beforeEach(async () => { + db = new Database(':memory:'); + await db.initialize(); + auditTrail = new TemplateAuditTrail(db); + await db.run( + ` + INSERT INTO notification_templates ( + id, name, type, body, created_at, updated_at + ) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, + ['tmpl-audit', 'Audit Template', 'email', 'Body'], + ); + }); + + afterEach(async () => { + await db.close(); + }); + + it('rejects audit records without actor', async () => { + await expect( + auditTrail.record({ + templateId: 'tmpl-audit', + actor: ' ', + previousSnapshot: { + id: 'tmpl-audit', + name: 'Audit Template', + type: 'email', + body: 'Body', + createdAt: new Date(), + updatedAt: new Date(), + }, + newSnapshot: { + id: 'tmpl-audit', + name: 'Audit Template', + type: 'email', + body: 'Updated body', + createdAt: new Date(), + updatedAt: new Date(), + }, + }), + ).rejects.toThrow('Actor is required'); + }); + + it('returns empty history for templates with no updates', async () => { + await expect(auditTrail.getByTemplateId('tmpl-audit')).resolves.toEqual([]); + }); +}); diff --git a/listener/src/services/notification-template-repository.ts b/listener/src/services/notification-template-repository.ts new file mode 100644 index 0000000..a258c06 --- /dev/null +++ b/listener/src/services/notification-template-repository.ts @@ -0,0 +1,202 @@ +import { Database } from '../database/database'; +import logger from '../utils/logger'; +import { + CreateNotificationTemplateInput, + NotificationTemplate, + NotificationTemplateRow, + TemplateAuditRecord, + UpdateNotificationTemplateInput, +} from '../types/notification-template'; +import { TemplateAuditTrail } from './template-audit-trail'; +import { NotificationTemplateCache } from './notification-template-cache'; + +export class TemplateNotFoundError extends Error { + constructor(templateId: string) { + super(`Notification template not found: ${templateId}`); + this.name = 'TemplateNotFoundError'; + } +} + +export class TemplateValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'TemplateValidationError'; + } +} + +/** + * Persists notification templates and records immutable audit entries on update. + */ +export class NotificationTemplateRepository { + constructor( + private readonly db: Database, + private readonly auditTrail: TemplateAuditTrail = new TemplateAuditTrail(db), + private readonly cache?: NotificationTemplateCache, + ) {} + + async create(input: CreateNotificationTemplateInput): Promise { + this.validateTemplateInput(input.id, input.name, input.body); + + const now = new Date(); + const sql = ` + INSERT INTO notification_templates ( + id, name, type, subject, body, variables, metadata, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `; + + const params = [ + input.id, + input.name, + input.type, + input.subject ?? null, + input.body, + input.variables ? JSON.stringify(input.variables) : null, + input.metadata ? JSON.stringify(input.metadata) : null, + now.toISOString(), + now.toISOString(), + ]; + + await this.db.run(sql, params); + const template = await this.getById(input.id); + if (!template) { + throw new Error(`Failed to load template after create: ${input.id}`); + } + + logger.info('Notification template created', { templateId: input.id }); + return template; + } + + async getById(templateId: string): Promise { + const row = await this.db.get( + 'SELECT * FROM notification_templates WHERE id = ?', + [templateId], + ); + return row ? this.rowToModel(row) : undefined; + } + + async update( + templateId: string, + input: UpdateNotificationTemplateInput, + actor: string, + ): Promise { + const trimmedActor = actor?.trim(); + if (!trimmedActor) { + throw new TemplateValidationError('Actor is required for template updates'); + } + + const existing = await this.getById(templateId); + if (!existing) { + throw new TemplateNotFoundError(templateId); + } + + const nextName = input.name ?? existing.name; + const nextBody = input.body ?? existing.body; + this.validateTemplateInput(templateId, nextName, nextBody); + + const updated: NotificationTemplate = { + ...existing, + ...input, + name: nextName, + body: nextBody, + updatedAt: new Date(), + }; + + const hasChanges = this.hasTemplateChanges(existing, updated); + if (!hasChanges) { + return existing; + } + + await this.db.transaction(async () => { + await this.db.run( + ` + UPDATE notification_templates + SET + name = ?, + type = ?, + subject = ?, + body = ?, + variables = ?, + metadata = ? + WHERE id = ? + `, + [ + updated.name, + updated.type, + updated.subject ?? null, + updated.body, + updated.variables ? JSON.stringify(updated.variables) : null, + updated.metadata ? JSON.stringify(updated.metadata) : null, + templateId, + ], + ); + + await this.auditTrail.record({ + templateId, + actor: trimmedActor, + previousSnapshot: existing, + newSnapshot: updated, + }); + }); + + this.cache?.invalidate(templateId); + + const persisted = await this.getById(templateId); + if (!persisted) { + throw new Error(`Failed to load template after update: ${templateId}`); + } + + logger.info('Notification template updated', { templateId, actor: trimmedActor }); + return persisted; + } + + async getUpdateHistory(templateId: string): Promise { + return this.auditTrail.getByTemplateId(templateId); + } + + private validateTemplateInput(templateId: string, name: string, body: string): void { + if (!templateId?.trim()) { + throw new TemplateValidationError('Template ID is required'); + } + if (!name?.trim()) { + throw new TemplateValidationError('Template name is required'); + } + if (!body?.trim()) { + throw new TemplateValidationError('Template body is required'); + } + } + + private hasTemplateChanges( + previous: NotificationTemplate, + next: NotificationTemplate, + ): boolean { + return JSON.stringify(this.snapshotForComparison(previous)) + !== JSON.stringify(this.snapshotForComparison(next)); + } + + private snapshotForComparison(template: NotificationTemplate): Record { + return { + id: template.id, + name: template.name, + type: template.type, + subject: template.subject, + body: template.body, + variables: template.variables, + metadata: template.metadata, + createdAt: template.createdAt, + }; + } + + private rowToModel(row: NotificationTemplateRow): NotificationTemplate { + return { + id: row.id, + name: row.name, + type: row.type, + subject: row.subject ?? undefined, + body: row.body, + variables: row.variables ? JSON.parse(row.variables) : undefined, + metadata: row.metadata ? JSON.parse(row.metadata) : undefined, + createdAt: new Date(row.created_at), + updatedAt: new Date(row.updated_at), + }; + } +} diff --git a/listener/src/services/notification-template-service.test.ts b/listener/src/services/notification-template-service.test.ts new file mode 100644 index 0000000..153e022 --- /dev/null +++ b/listener/src/services/notification-template-service.test.ts @@ -0,0 +1,46 @@ +import { Database } from '../database/database'; +import { NotificationTemplateCache } from './notification-template-cache'; +import { NotificationTemplateRepository } from './notification-template-repository'; +import { NotificationTemplateService } from './notification-template-service'; +import { TemplateAuditTrail } from './template-audit-trail'; + +describe('NotificationTemplateService', () => { + let db: Database; + let service: NotificationTemplateService; + let cache: NotificationTemplateCache; + + beforeEach(async () => { + db = new Database(':memory:'); + await db.initialize(); + cache = new NotificationTemplateCache(60, 0); + const repository = new NotificationTemplateRepository(db, new TemplateAuditTrail(db), cache); + service = new NotificationTemplateService(repository, cache); + }); + + afterEach(async () => { + await db.close(); + }); + + it('routes updates through the repository and invalidates cache', async () => { + await service.create({ + id: 'svc-template', + name: 'Service Template', + type: 'email', + body: 'Original body', + }); + + const cached = await service.getById('svc-template'); + expect(cached?.body).toBe('Original body'); + expect(cache.has('svc-template')).toBe(true); + + await service.update('svc-template', { body: 'Updated body' }, 'service-admin'); + + expect(cache.has('svc-template')).toBe(false); + const refreshed = await service.getById('svc-template'); + expect(refreshed?.body).toBe('Updated body'); + + const history = await service.getAuditHistory('svc-template'); + expect(history).toHaveLength(1); + expect(history[0].actor).toBe('service-admin'); + }); +}); diff --git a/listener/src/services/notification-template-service.ts b/listener/src/services/notification-template-service.ts new file mode 100644 index 0000000..f90d8ea --- /dev/null +++ b/listener/src/services/notification-template-service.ts @@ -0,0 +1,41 @@ +import { + CreateNotificationTemplateInput, + NotificationTemplate, + TemplateAuditRecord, + UpdateNotificationTemplateInput, +} from '../types/notification-template'; +import { NotificationTemplateRepository } from './notification-template-repository'; +import { getTemplateCache, NotificationTemplateCache } from './notification-template-cache'; + +/** + * Application entry point for notification templates. + * All reads go through cache; all writes go through the repository (with audit). + */ +export class NotificationTemplateService { + constructor( + private readonly repository: NotificationTemplateRepository, + private readonly cache: NotificationTemplateCache = getTemplateCache(), + ) {} + + async create(input: CreateNotificationTemplateInput): Promise { + const template = await this.repository.create(input); + this.cache.set(template.id, template); + return template; + } + + async getById(templateId: string): Promise { + return this.cache.getOrLoad(templateId, () => this.repository.getById(templateId)); + } + + async update( + templateId: string, + input: UpdateNotificationTemplateInput, + actor: string, + ): Promise { + return this.repository.update(templateId, input, actor); + } + + async getAuditHistory(templateId: string): Promise { + return this.repository.getUpdateHistory(templateId); + } +} diff --git a/listener/src/services/template-audit-trail.ts b/listener/src/services/template-audit-trail.ts new file mode 100644 index 0000000..13f66fe --- /dev/null +++ b/listener/src/services/template-audit-trail.ts @@ -0,0 +1,85 @@ +import { Database } from '../database/database'; +import logger from '../utils/logger'; +import { + NotificationTemplate, + TemplateAuditAction, + TemplateAuditRecord, + TemplateAuditRecordRow, +} from '../types/notification-template'; + +export interface RecordTemplateAuditInput { + templateId: string; + actor: string; + action?: TemplateAuditAction; + previousSnapshot: NotificationTemplate; + newSnapshot: NotificationTemplate; +} + +/** + * Append-only audit trail for notification template modifications. + * Records are persisted in SQLite with triggers that block UPDATE/DELETE. + */ +export class TemplateAuditTrail { + constructor(private readonly db: Database) {} + + async record(input: RecordTemplateAuditInput): Promise { + const actor = input.actor?.trim(); + if (!actor) { + throw new Error('Actor is required for template audit records'); + } + if (!input.templateId?.trim()) { + throw new Error('Template ID is required for template audit records'); + } + + const changedAt = new Date().toISOString(); + const sql = ` + INSERT INTO notification_template_audit_log ( + template_id, actor, action, changed_at, previous_snapshot, new_snapshot + ) VALUES (?, ?, ?, ?, ?, ?) + `; + + const params = [ + input.templateId, + actor, + input.action ?? 'UPDATE', + changedAt, + JSON.stringify(input.previousSnapshot), + JSON.stringify(input.newSnapshot), + ]; + + const result = await this.db.run(sql, params); + logger.info('Template audit record created', { + auditId: result.lastID, + templateId: input.templateId, + actor, + action: input.action ?? 'UPDATE', + }); + return result.lastID; + } + + async getByTemplateId(templateId: string): Promise { + const rows = await this.db.all( + ` + SELECT * + FROM notification_template_audit_log + WHERE template_id = ? + ORDER BY changed_at ASC, id ASC + `, + [templateId], + ); + + return rows.map((row) => this.rowToModel(row)); + } + + private rowToModel(row: TemplateAuditRecordRow): TemplateAuditRecord { + return { + id: row.id, + templateId: row.template_id, + actor: row.actor, + action: row.action as TemplateAuditAction, + changedAt: new Date(row.changed_at), + previousSnapshot: JSON.parse(row.previous_snapshot) as NotificationTemplate, + newSnapshot: JSON.parse(row.new_snapshot) as NotificationTemplate, + }; + } +} diff --git a/listener/src/types/notification-template.ts b/listener/src/types/notification-template.ts new file mode 100644 index 0000000..68fdddb --- /dev/null +++ b/listener/src/types/notification-template.ts @@ -0,0 +1,64 @@ +export interface NotificationTemplate { + id: string; + name: string; + type: string; + subject?: string; + body: string; + variables?: string[]; + metadata?: Record; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateNotificationTemplateInput { + id: string; + name: string; + type: string; + subject?: string; + body: string; + variables?: string[]; + metadata?: Record; +} + +export interface UpdateNotificationTemplateInput { + name?: string; + type?: string; + subject?: string; + body?: string; + variables?: string[]; + metadata?: Record; +} + +export type TemplateAuditAction = 'UPDATE'; + +export interface TemplateAuditRecord { + id: number; + templateId: string; + actor: string; + action: TemplateAuditAction; + changedAt: Date; + previousSnapshot: NotificationTemplate; + newSnapshot: NotificationTemplate; +} + +export interface NotificationTemplateRow { + id: string; + name: string; + type: string; + subject: string | null; + body: string; + variables: string | null; + metadata: string | null; + created_at: string; + updated_at: string; +} + +export interface TemplateAuditRecordRow { + id: number; + template_id: string; + actor: string; + action: string; + changed_at: string; + previous_snapshot: string; + new_snapshot: string; +} diff --git a/listener/src/utils/request-actor.ts b/listener/src/utils/request-actor.ts new file mode 100644 index 0000000..3e02912 --- /dev/null +++ b/listener/src/utils/request-actor.ts @@ -0,0 +1,31 @@ +import http from 'http'; + +/** + * Derives an accountable actor identifier from request auth headers or client IP. + * Used for audit trails on admin mutations (e.g. template updates). + */ +export function resolveRequestActor(req: http.IncomingMessage): string { + const apiKeyHeader = req.headers['x-api-key']; + if (typeof apiKeyHeader === 'string' && apiKeyHeader.trim()) { + return `api-key:${apiKeyHeader.trim()}`; + } + + const authHeader = req.headers['authorization']; + if (typeof authHeader === 'string' && authHeader.toLowerCase().startsWith('bearer ')) { + const token = authHeader.slice(7).trim(); + if (token) { + return `bearer:${token}`; + } + } + + const xForwardedFor = req.headers['x-forwarded-for']; + if (typeof xForwardedFor === 'string' && xForwardedFor.trim()) { + const clientIp = xForwardedFor.split(',')[0].trim(); + if (clientIp) { + return `ip:${clientIp}`; + } + } + + const remoteIp = req.socket.remoteAddress || 'unknown'; + return `ip:${remoteIp}`; +} From 7cf746984b2d6ba953c3e98470400c26f26e255c Mon Sep 17 00:00:00 2001 From: Ved-viraj Date: Mon, 22 Jun 2026 17:43:23 +0100 Subject: [PATCH 2/2] fix(contract): remove trailing whitespace to pass rustfmt CI --- contract/contracts/hello-world/src/base/events.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contract/contracts/hello-world/src/base/events.rs b/contract/contracts/hello-world/src/base/events.rs index 7feeac9..8d7f001 100644 --- a/contract/contracts/hello-world/src/base/events.rs +++ b/contract/contracts/hello-world/src/base/events.rs @@ -4,7 +4,7 @@ use soroban_sdk::{contractevent, contracttype, Address, BytesN, String}; /// /// Off-chain consumers (listeners, indexers, dashboards) often only care about a /// subset of the events the contract emits. Each event carries its category as a -/// trailing, indexed event topic so consumers can subscribe to or filter out +/// trailing, indexed event topic so consumers can subscribe to or filter out /// whole categories without having to decode the event payload first. /// /// # Backward compatibility @@ -31,7 +31,7 @@ pub enum NotificationCategory { /// /// Off-chain consumers (alerting, dashboards, paging) often route notifications /// by priority rather than (or in addition to) category. Each event carries its -/// priority as a trailing, indexed event topic so consumers can subscribe to +/// priority as a trailing, indexed event topic so consumers can subscribe to /// or page on high-priority notifications without decoding the payload. /// /// # Backward compatibility