From b5ff33d716a4d4ef609dd811ae66a46465a795df Mon Sep 17 00:00:00 2001 From: Mauricio Barragan Date: Wed, 10 Jun 2026 23:51:52 -0600 Subject: [PATCH 1/7] Add Husky pre-commit with eslint and Sentry env convention (M0). Co-authored-by: Cursor --- .env.example | 3 + .husky/pre-commit | 1 + .prettierrc | 6 + README.md | 7 + eslint.config.mjs | 31 + package-lock.json | 2122 ++++++++++++++++++++++++++++++++++++++++++++- package.json | 24 +- 7 files changed, 2191 insertions(+), 3 deletions(-) create mode 100644 .husky/pre-commit create mode 100644 .prettierrc create mode 100644 eslint.config.mjs diff --git a/.env.example b/.env.example index 9cc97ad..0b57041 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,6 @@ DEMO_MODE=false # Feed recorder (NDJSON to data/) RECORD_FEED=false + +# Sentry — https://sentry.io (project: arbpulse) +SENTRY_DSN= diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..2312dc5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..654f09a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "tabWidth": 2 +} diff --git a/README.md b/README.md index 6311e18..96a8f53 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,13 @@ Eso no indica que el bot “no funcione”: indica que el filtro económico es e --- +## Production practices + +- **Pre-commit:** Husky runs lint-staged (`eslint --fix`, `prettier --write`) on staged `*.ts` / `*.tsx` in `src/` and `web/src/`. +- **Observability:** Sentry planned — set `SENTRY_DSN` in `.env` after creating the `arbpulse` project in Sentry. + +--- + ## Licencia MIT — ver archivo `LICENSE` cuando se añada al repositorio. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..c6ad2b6 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,31 @@ +import eslint from "@eslint/js"; +import reactHooks from "eslint-plugin-react-hooks"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["web/dist/**", "data/**", "**/node_modules/**"] }, + { + files: ["src/**/*.ts"], + extends: [eslint.configs.recommended, ...tseslint.configs.recommended], + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: globals.node, + }, + }, + { + files: ["web/src/**/*.{ts,tsx}"], + extends: [eslint.configs.recommended, ...tseslint.configs.recommended], + plugins: { "react-hooks": reactHooks }, + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: globals.browser, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + rules: { + ...reactHooks.configs.recommended.rules, + }, + }, +); diff --git a/package-lock.json b/package-lock.json index 712917f..2906454 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,12 +14,20 @@ "ws": "^8.18.0" }, "devDependencies": { + "@eslint/js": "^9.18.0", "@types/express": "^4.17.21", "@types/node": "^22.10.5", "@types/swagger-ui-express": "^4.1.8", "@types/ws": "^8.5.13", + "eslint": "^9.18.0", + "eslint-plugin-react-hooks": "^5.1.0", + "globals": "^15.14.0", + "husky": "^9.1.7", + "lint-staged": "^16.4.0", "openapi-types": "^12.1.3", - "typescript": "^5.7.3" + "prettier": "^3.8.3", + "typescript": "^5.7.3", + "typescript-eslint": "^8.26.0" }, "engines": { "node": ">=20" @@ -441,6 +449,279 @@ "node": ">=18" } }, + "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/eslint-utils/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-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/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.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/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -469,6 +750,13 @@ "@types/node": "*" } }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", @@ -502,6 +790,13 @@ "dev": true, "license": "MIT" }, + "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/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -587,6 +882,388 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", + "integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/type-utils": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.61.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz", + "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz", + "integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.61.0", + "@typescript-eslint/types": "^8.61.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz", + "integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz", + "integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz", + "integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz", + "integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz", + "integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.61.0", + "@typescript-eslint/tsconfig-utils": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz", + "integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz", + "integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -600,12 +1277,111 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "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/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": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "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/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/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.5", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", @@ -630,6 +1406,17 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -668,6 +1455,110 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/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/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -704,6 +1595,21 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -713,6 +1619,13 @@ "ms": "2.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/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -752,6 +1665,13 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -761,6 +1681,19 @@ "node": ">= 0.8" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -838,6 +1771,211 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "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": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "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/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -847,6 +1985,13 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", @@ -893,6 +2038,58 @@ "url": "https://opencollective.com/express" } }, + "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-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -911,6 +2108,44 @@ "node": ">= 0.8" } }, + "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/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "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/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -952,6 +2187,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -989,6 +2237,32 @@ "node": ">= 0.4" } }, + "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": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "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", @@ -1001,6 +2275,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1045,6 +2329,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -1057,6 +2357,43 @@ "node": ">=0.10.0" } }, + "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/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1072,6 +2409,235 @@ "node": ">= 0.10" } }, + "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": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "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/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-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/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/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/lint-staged": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", + "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.3", + "listr2": "^9.0.5", + "picomatch": "^4.0.3", + "string-argv": "^0.3.2", + "tinyexec": "^1.0.4", + "yaml": "^2.8.2" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "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/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/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1141,12 +2707,45 @@ "node": ">= 0.6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1180,6 +2779,22 @@ "node": ">= 0.8" } }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/openapi-types": { "version": "12.1.3", "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", @@ -1187,6 +2802,69 @@ "dev": true, "license": "MIT" }, + "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", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/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/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1196,12 +2874,71 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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/prettier": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1215,6 +2952,16 @@ "node": ">= 0.10" } }, + "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/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -1254,6 +3001,40 @@ "node": ">= 0.8" } }, + "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/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1280,6 +3061,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -1331,6 +3125,29 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -1403,6 +3220,49 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1412,6 +3272,75 @@ "node": ">= 0.8" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/swagger-ui-dist": { "version": "5.32.6", "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.6.tgz", @@ -1436,6 +3365,33 @@ "express": ">=4.0.0 || >=5.0.0-beta" } }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1445,6 +3401,19 @@ "node": ">=0.6" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tsx": { "version": "4.22.4", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", @@ -1463,6 +3432,19 @@ "fsevents": "~2.3.3" } }, + "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-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -1490,6 +3472,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz", + "integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.61.0", + "@typescript-eslint/parser": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -1506,6 +3512,16 @@ "node": ">= 0.8" } }, + "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/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -1524,6 +3540,81 @@ "node": ">= 0.8" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "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/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -1544,6 +3635,35 @@ "optional": true } } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 649b0a4..4f7cbd2 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,19 @@ "start": "tsx src/index.ts", "build": "npm --prefix web install && npm --prefix web run build", "typecheck": "tsc --noEmit && npm --prefix web run typecheck", - "test": "node scripts/run-tests.mjs" + "test": "node scripts/run-tests.mjs", + "lint": "eslint .", + "prepare": "husky" + }, + "lint-staged": { + "src/**/*.ts": [ + "eslint --fix", + "prettier --write" + ], + "web/src/**/*.{ts,tsx}": [ + "eslint --fix", + "prettier --write" + ] }, "dependencies": { "express": "^4.21.2", @@ -21,11 +33,19 @@ "ws": "^8.18.0" }, "devDependencies": { + "@eslint/js": "^9.18.0", "@types/express": "^4.17.21", "@types/node": "^22.10.5", "@types/swagger-ui-express": "^4.1.8", "@types/ws": "^8.5.13", + "eslint": "^9.18.0", + "eslint-plugin-react-hooks": "^5.1.0", + "globals": "^15.14.0", + "husky": "^9.1.7", + "lint-staged": "^16.4.0", "openapi-types": "^12.1.3", - "typescript": "^5.7.3" + "prettier": "^3.8.3", + "typescript": "^5.7.3", + "typescript-eslint": "^8.26.0" } } From f4def0c214a38f1158049749949f13085e3a884c Mon Sep 17 00:00:00 2001 From: Mauricio Barragan Date: Thu, 11 Jun 2026 00:04:12 -0600 Subject: [PATCH 2/7] Refactor API contracts to Zod, zod-to-openapi, and Scalar. Replace hand-written OpenAPI and Swagger UI with schema-driven docs at /api-docs and Zod validation on request bodies. Co-authored-by: Cursor --- .env.example | 2 + README.md | 5 +- package-lock.json | 167 ++-- package.json | 8 +- src/index.ts | 19 +- src/interfaces/http/openapi.ts | 987 ++++++----------------- src/interfaces/http/routes.ts | 61 +- src/interfaces/http/schemas/common.ts | 59 ++ src/interfaces/http/schemas/domain.ts | 161 ++++ src/interfaces/http/schemas/requests.ts | 53 ++ src/interfaces/http/schemas/responses.ts | 42 + src/interfaces/http/validate.ts | 18 + web/package-lock.json | 18 +- 13 files changed, 788 insertions(+), 812 deletions(-) create mode 100644 src/interfaces/http/schemas/common.ts create mode 100644 src/interfaces/http/schemas/domain.ts create mode 100644 src/interfaces/http/schemas/requests.ts create mode 100644 src/interfaces/http/schemas/responses.ts create mode 100644 src/interfaces/http/validate.ts diff --git a/.env.example b/.env.example index 0b57041..394755a 100644 --- a/.env.example +++ b/.env.example @@ -23,5 +23,7 @@ DEMO_MODE=false # Feed recorder (NDJSON to data/) RECORD_FEED=false +# API docs — Scalar at GET /api-docs (OpenAPI generated from Zod; no env vars required) + # Sentry — https://sentry.io (project: arbpulse) SENTRY_DSN= diff --git a/README.md b/README.md index 96a8f53..a52c219 100644 --- a/README.md +++ b/README.md @@ -301,9 +301,9 @@ Respuestas REST siguen la forma `{ success, data?, error? }`. | `POST` | `/api/control/record` | `{ "enabled": boolean }` — grabación NDJSON en `data/` | | `POST` | `/api/control/threshold` | `{ "pct": number }` (atajo de `PATCH /api/config`) | | `POST` | `/api/control/max-trade` | `{ "btc": number }` (atajo de `PATCH /api/config`) | -| `GET` | `/api-docs` | **Swagger UI** — documentación interactiva OpenAPI 3.0 | +| `GET` | `/api-docs` | **Scalar** — documentación interactiva OpenAPI 3.0 | -Contrato completo y schemas en `src/interfaces/http/openapi.ts`. +Contrato generado desde Zod (`src/interfaces/http/schemas/`) con `@asteasolutions/zod-to-openapi`; registro de rutas en `src/interfaces/http/openapi.ts`. --- @@ -346,6 +346,7 @@ Eso no indica que el bot “no funcione”: indica que el filtro económico es e ## Production practices - **Pre-commit:** Husky runs lint-staged (`eslint --fix`, `prettier --write`) on staged `*.ts` / `*.tsx` in `src/` and `web/src/`. +- **API contracts:** Zod schemas per REST endpoint → OpenAPI via `@asteasolutions/zod-to-openapi` → Scalar UI at `/api-docs`. Request bodies validated with Zod in route handlers. - **Observability:** Sentry planned — set `SENTRY_DSN` in `.env` after creating the `arbpulse` project in Sentry. --- diff --git a/package-lock.json b/package-lock.json index 2906454..631a0fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,23 +8,23 @@ "name": "arb-pulse", "version": "1.0.0", "dependencies": { + "@asteasolutions/zod-to-openapi": "^8.5.0", + "@scalar/express-api-reference": "^0.8.48", "express": "^4.21.2", - "swagger-ui-express": "^5.0.1", "tsx": "^4.19.2", - "ws": "^8.18.0" + "ws": "^8.18.0", + "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^9.18.0", "@types/express": "^4.17.21", "@types/node": "^22.10.5", - "@types/swagger-ui-express": "^4.1.8", "@types/ws": "^8.5.13", "eslint": "^9.18.0", "eslint-plugin-react-hooks": "^5.1.0", "globals": "^15.14.0", "husky": "^9.1.7", "lint-staged": "^16.4.0", - "openapi-types": "^12.1.3", "prettier": "^3.8.3", "typescript": "^5.7.3", "typescript-eslint": "^8.26.0" @@ -33,6 +33,18 @@ "node": ">=20" } }, + "node_modules/@asteasolutions/zod-to-openapi": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.5.0.tgz", + "integrity": "sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==", + "license": "MIT", + "dependencies": { + "openapi3-ts": "^4.1.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", @@ -722,12 +734,53 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@scarf/scarf": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", - "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", - "hasInstallScript": true, - "license": "Apache-2.0" + "node_modules/@scalar/core": { + "version": "0.3.45", + "resolved": "https://registry.npmjs.org/@scalar/core/-/core-0.3.45.tgz", + "integrity": "sha512-f3jyzColUUcu3eYfjslgNjG2JABuFU8WR3P7a9GevMpisW3skWUqVVffyC1P4w4i9TVRk9FUjYEtGeP10Rw9lQ==", + "license": "MIT", + "dependencies": { + "@scalar/types": "0.6.10" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@scalar/express-api-reference": { + "version": "0.8.48", + "resolved": "https://registry.npmjs.org/@scalar/express-api-reference/-/express-api-reference-0.8.48.tgz", + "integrity": "sha512-DtdWhpxSPYAEuWNa0BZ8uVgvmfXytD6jRRJUlFi9RBeVm2Gectbc5dDtHqekmllQ4iiwyvGm8RcEhY/TZm4KbA==", + "license": "MIT", + "dependencies": { + "@scalar/core": "0.3.45" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@scalar/helpers": { + "version": "0.2.18", + "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.2.18.tgz", + "integrity": "sha512-w1d4tpNEVZ293oB2BAgLrS0kVPUtG3eByNmOCJA5eK9vcT4D3cmsGtWjUaaqit0BQCsBFHK51rasGvSWnApYTw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@scalar/types": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.6.10.tgz", + "integrity": "sha512-fZkelRwcEeAhsn4c0wjYXWrzSzLaEyfxTn/eazXJ4XfCIsgJTQyK0FD8mnOBZJ2vEIbtT2E1mBKnCbDxrJIlxA==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.2.18", + "nanoid": "^5.1.6", + "type-fest": "^5.3.1", + "zod": "^4.3.5" + }, + "engines": { + "node": ">=20" + } }, "node_modules/@types/body-parser": { "version": "1.19.6", @@ -861,17 +914,6 @@ "@types/node": "*" } }, - "node_modules/@types/swagger-ui-express": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", - "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*", - "@types/serve-static": "*" - } - }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -2739,6 +2781,24 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2795,12 +2855,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/openapi-types": { - "version": "12.1.3", - "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", - "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", - "dev": true, - "license": "MIT" + "node_modules/openapi3-ts": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.6.0.tgz", + "integrity": "sha512-a4sfn6L2sIShhtzJqmjGrARvxAW/3F2BJDdyRVvNF9VhAsZSh5hSyI3a9TNvmzBxXmq66nY5LNT5bQcBxYAZZg==", + "license": "MIT", + "dependencies": { + "yaml": "^2.9.0" + } }, "node_modules/optionator": { "version": "0.9.4", @@ -3341,28 +3403,16 @@ "node": ">=8" } }, - "node_modules/swagger-ui-dist": { - "version": "5.32.6", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.6.tgz", - "integrity": "sha512-75ttZNaYCLoFPnozPZcTUU6mS3wKT8l7WLjU5zJSHFeJa23i5vtnze6IiCl4jDMPeQTXVXIgovq4M11NNfQvSA==", - "license": "Apache-2.0", - "dependencies": { - "@scarf/scarf": "=1.4.0" - } - }, - "node_modules/swagger-ui-express": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", - "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", "license": "MIT", - "dependencies": { - "swagger-ui-dist": ">=5.0.0" - }, "engines": { - "node": ">= v0.10.32" + "node": ">=20" }, - "peerDependencies": { - "express": ">=4.0.0 || >=5.0.0-beta" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/tinyexec": { @@ -3445,6 +3495,21 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -3640,7 +3705,6 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -3664,6 +3728,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 4f7cbd2..297d2d6 100644 --- a/package.json +++ b/package.json @@ -27,23 +27,23 @@ ] }, "dependencies": { + "@asteasolutions/zod-to-openapi": "^8.5.0", + "@scalar/express-api-reference": "^0.8.48", "express": "^4.21.2", - "swagger-ui-express": "^5.0.1", "tsx": "^4.19.2", - "ws": "^8.18.0" + "ws": "^8.18.0", + "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^9.18.0", "@types/express": "^4.17.21", "@types/node": "^22.10.5", - "@types/swagger-ui-express": "^4.1.8", "@types/ws": "^8.5.13", "eslint": "^9.18.0", "eslint-plugin-react-hooks": "^5.1.0", "globals": "^15.14.0", "husky": "^9.1.7", "lint-staged": "^16.4.0", - "openapi-types": "^12.1.3", "prettier": "^3.8.3", "typescript": "^5.7.3", "typescript-eslint": "^8.26.0" diff --git a/src/index.ts b/src/index.ts index d80a172..1c5a367 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import express from "express"; -import swaggerUi from "swagger-ui-express"; +import { apiReference } from "@scalar/express-api-reference"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; @@ -8,7 +8,7 @@ import { bootstrap, config } from "./composition/bootstrap.js"; import { runtime } from "./infrastructure/config/runtime.js"; import { SseHub } from "./interfaces/sse/sse.js"; import { createRouter } from "./interfaces/http/routes.js"; -import { openapiSpec } from "./interfaces/http/openapi.js"; +import { openapiDocument } from "./interfaces/http/openapi.js"; const log = createLogger("server"); @@ -26,10 +26,13 @@ function main(): void { server.use(express.json()); server.use("/api", createRouter(ctx.application, sse)); - server.use("/api-docs", swaggerUi.serve, swaggerUi.setup(openapiSpec, { - customSiteTitle: "Arb Pulse — API Docs", - swaggerOptions: { defaultModelsExpandDepth: 2, defaultModelExpandDepth: 2 }, - })); + server.use( + "/api-docs", + apiReference({ + content: openapiDocument, + pageTitle: "Arb Pulse — API Docs", + }), + ); if (existsSync(webDist)) { server.use(express.static(webDist)); @@ -40,7 +43,9 @@ function main(): void { server.get("/", (_req, res) => { res .status(200) - .send("Backend running. Frontend not built yet — run `npm run build` (or `npm run dev:web`)."); + .send( + "Backend running. Frontend not built yet — run `npm run build` (or `npm run dev:web`).", + ); }); } diff --git a/src/interfaces/http/openapi.ts b/src/interfaces/http/openapi.ts index 0324531..66a8e15 100644 --- a/src/interfaces/http/openapi.ts +++ b/src/interfaces/http/openapi.ts @@ -1,745 +1,296 @@ /** - * OpenAPI 3.0 specification for the Arb Pulse REST API. - * - * Arb Pulse is a **simulated** cross-exchange BTC/USDT arbitrage system. - * No real funds are at risk — all trades are paper-executed against live order - * book data from Kraken, Bybit, OKX and Binance (or a synthetic feed in demo mode). + * OpenAPI 3.0 document generated from Zod schemas via @asteasolutions/zod-to-openapi. + * Served interactively at /api-docs through Scalar. */ -import type { OpenAPIV3 } from "openapi-types"; +import { + OpenAPIRegistry, + OpenApiGeneratorV3, +} from "@asteasolutions/zod-to-openapi"; +import { + ErrorEnvelopeSchema, + SuccessEnvelopeNoDataSchema, +} from "./schemas/common.js"; +import { + ConfigPatchSchema, + DemoControlBodySchema, + MaxTradeControlBodySchema, + RecordControlBodySchema, + ThresholdControlBodySchema, +} from "./schemas/requests.js"; +import { + ConfigResponseSchema, + DemoModeResponseSchema, + HealthResponseSchema, + MaxTradeResponseSchema, + RecordFeedResponseSchema, + StateResponseSchema, + ThresholdResponseSchema, +} from "./schemas/responses.js"; +import { StateSnapshotSchema } from "./schemas/domain.js"; -// --------------------------------------------------------------------------- -// Reusable schema components -// --------------------------------------------------------------------------- +const registry = new OpenAPIRegistry(); -const schemas: Record = { - ExchangeId: { - type: "string", - enum: ["kraken", "bybit", "okx", "binance"], - description: "One of the supported exchanges.", - }, +registry.register("ErrorEnvelope", ErrorEnvelopeSchema); +registry.register("SuccessEnvelopeNoData", SuccessEnvelopeNoDataSchema); +registry.register("StateSnapshot", StateSnapshotSchema); - FeedStatus: { - type: "string", - enum: ["connecting", "live", "stale", "down"], - description: "Connectivity state of the exchange WebSocket feed.", - }, - - CircuitState: { - type: "string", - enum: ["running", "paused", "tripped"], - description: - "State of the circuit-breaker: `running` = normal, `paused` = manually paused, `tripped` = auto-paused after N consecutive losses.", - }, - - OpportunityStatus: { - type: "string", - enum: [ - "executed", - "executed_partial", - "rejected_fees", - "rejected_liquidity", - "rejected_risk", - "rejected_flicker", - "rejected_stale", - "pending_confirm", - ], - description: "Disposition of a detected arbitrage opportunity.", - }, - - BestQuote: { - type: "object", - description: "Best bid/ask snapshot for one exchange, as seen by the engine.", - required: ["exchange", "bid", "bidQty", "ask", "askQty", "recvTs", "status", "ageMs"], - properties: { - exchange: { $ref: "#/components/schemas/ExchangeId" }, - bid: { type: "number", nullable: true, description: "Best bid price in USDT (null when feed is down)." }, - bidQty: { type: "number", nullable: true, description: "Quantity available at the best bid (BTC)." }, - ask: { type: "number", nullable: true, description: "Best ask price in USDT (null when feed is down)." }, - askQty: { type: "number", nullable: true, description: "Quantity available at the best ask (BTC)." }, - recvTs: { type: "integer", nullable: true, description: "Local receive timestamp (ms since epoch)." }, - status: { $ref: "#/components/schemas/FeedStatus" }, - ageMs: { type: "integer", nullable: true, description: "Milliseconds since the last update was received." }, - }, - }, - - Wallet: { - type: "object", - description: - "Simulated per-exchange wallet. Each exchange starts with pre-positioned USDT **and** BTC so buy/sell legs execute in parallel without on-chain transfers.", - required: ["exchange", "usdt", "btc"], - properties: { - exchange: { $ref: "#/components/schemas/ExchangeId" }, - usdt: { type: "number", description: "USDT balance at this exchange." }, - btc: { type: "number", description: "BTC balance at this exchange." }, - }, - }, - - Opportunity: { - type: "object", - description: - "An arbitrage opportunity detected by the engine. May have been executed or rejected for various reasons (fees, liquidity, flicker, stale quotes, risk).", - required: [ - "id", "ts", "buyExchange", "sellExchange", - "topBuyAsk", "topSellBid", "volumeBtc", - "buyVwap", "sellVwap", - "grossSpread", "grossSpreadPct", - "feeBuy", "feeSell", - "netProfit", "netProfitPct", - "status", "reason", "demo", - ], - properties: { - id: { type: "string" }, - ts: { type: "integer", description: "Detection timestamp (ms epoch)." }, - buyExchange: { $ref: "#/components/schemas/ExchangeId" }, - sellExchange: { $ref: "#/components/schemas/ExchangeId" }, - topBuyAsk: { type: "number", description: "Top-of-book ask price on the buy exchange (USDT)." }, - topSellBid: { type: "number", description: "Top-of-book bid price on the sell exchange (USDT)." }, - volumeBtc: { - type: "number", - description: "Volume evaluated (BTC) after applying liquidity depth and wallet inventory caps.", - }, - buyVwap: { - type: "number", - description: "Volume-weighted average buy price after walking the order book depth (USDT). Slippage is embedded here.", - }, - sellVwap: { - type: "number", - description: "Volume-weighted average sell price after walking the order book depth (USDT). Slippage is embedded here.", - }, - grossSpread: { type: "number", description: "Raw spread (sellVwap − buyVwap) in USDT." }, - grossSpreadPct: { type: "number", description: "Gross spread as a percentage of buyVwap." }, - feeBuy: { type: "number", description: "Taker fee rate for the buy exchange (e.g. 0.0026 for Kraken)." }, - feeSell: { type: "number", description: "Taker fee rate for the sell exchange." }, - netProfit: { - type: "number", - description: "Net P&L in USDT: sellVwap·vol·(1−feeSell) − buyVwap·vol·(1+feeBuy). Negative = loss.", - }, - netProfitPct: { type: "number", description: "Net profit as a percentage of cost basis." }, - status: { $ref: "#/components/schemas/OpportunityStatus" }, - reason: { type: "string", description: "Human-readable explanation of the disposition." }, - demo: { - type: "boolean", - description: "True when this opportunity was synthetically injected by the demo feed — never real market data.", - }, - }, - }, - - Trade: { - type: "object", - description: - "A simulated trade that was executed (or partially executed). Includes latency-drift adjustment on both VWAP prices to model realistic fill slippage between detection and execution.", - required: [ - "id", "ts", "buyExchange", "sellExchange", - "volumeBtc", "requestedBtc", - "buyVwap", "sellVwap", "execBuyVwap", "execSellVwap", - "feeBuy", "feeSell", - "netProfit", "netProfitPct", - "partial", "demo", - ], - properties: { - id: { type: "string" }, - ts: { type: "integer", description: "Execution timestamp (ms epoch)." }, - buyExchange: { $ref: "#/components/schemas/ExchangeId" }, - sellExchange: { $ref: "#/components/schemas/ExchangeId" }, - volumeBtc: { type: "number", description: "Actual filled volume (BTC)." }, - requestedBtc: { type: "number", description: "Originally requested volume before partial fill." }, - buyVwap: { type: "number", description: "VWAP at detection time (pre-latency, USDT)." }, - sellVwap: { type: "number", description: "VWAP at detection time (pre-latency, USDT)." }, - execBuyVwap: { - type: "number", - description: "Simulated fill price after latency drift on the buy side (USDT). Reflects real-world price movement during order routing.", - }, - execSellVwap: { - type: "number", - description: "Simulated fill price after latency drift on the sell side (USDT).", - }, - feeBuy: { type: "number" }, - feeSell: { type: "number" }, - netProfit: { type: "number", description: "Realized net P&L in USDT (negative = loss)." }, - netProfitPct: { type: "number" }, - partial: { type: "boolean", description: "True when the fill was limited by order book depth or wallet balance." }, - demo: { type: "boolean" }, - }, - }, - - RebalanceEvent: { - type: "object", - description: - "An inter-exchange rebalance triggered to correct inventory drift. Withdrawal fees apply here — not per-trade.", - required: ["id", "ts", "fromExchange", "toExchange", "asset", "amount", "withdrawalFee", "reason"], - properties: { - id: { type: "string" }, - ts: { type: "integer" }, - fromExchange: { $ref: "#/components/schemas/ExchangeId" }, - toExchange: { $ref: "#/components/schemas/ExchangeId" }, - asset: { type: "string", enum: ["BTC", "USDT"] }, - amount: { type: "number" }, - withdrawalFee: { type: "number", description: "Withdrawal fee in the transferred asset." }, - reason: { type: "string" }, - }, - }, - - PnlPoint: { - type: "object", - description: "A cumulative P&L data point for the time-series chart.", - required: ["ts", "pnl"], - properties: { - ts: { type: "integer", description: "Timestamp (ms epoch)." }, - pnl: { type: "number", description: "Cumulative realized P&L in USDT at this timestamp." }, - }, - }, - - EngineStats: { - type: "object", - description: "Runtime statistics for the arbitrage engine.", - required: [ - "uptimeMs", "ticksProcessed", "opportunitiesDetected", - "tradesExecuted", "tradesRejected", - "realizedPnl", "consecutiveLosses", - "circuit", "demoMode", "avgTickMs", - ], - properties: { - uptimeMs: { type: "integer", description: "Engine uptime in milliseconds." }, - ticksProcessed: { type: "integer", description: "Total order-book ticks processed." }, - opportunitiesDetected: { type: "integer" }, - tradesExecuted: { type: "integer" }, - tradesRejected: { type: "integer" }, - realizedPnl: { type: "number", description: "Cumulative realized P&L in USDT." }, - consecutiveLosses: { - type: "integer", - description: "Consecutive losing trades since the last reset — triggers the circuit-breaker when it reaches the configured threshold.", - }, - circuit: { $ref: "#/components/schemas/CircuitState" }, - demoMode: { - type: "boolean", - description: "When true the engine consumes a synthetic (clearly-labelled) feed instead of live exchange data.", - }, - avgTickMs: { type: "number", description: "EWMA of tick processing time in milliseconds." }, - }, - }, - - PublicConfig: { - type: "object", - description: "Live engine configuration. All fields are read/write unless noted.", - required: [ - "minNetProfitPct", "maxTradeBtc", "staleMs", - "flickerConfirmMs", "latencyMs", - "activeExchanges", "defaults", "takerFees", "withdrawalFeesBtc", - ], - properties: { - minNetProfitPct: { - type: "number", - description: "Minimum net profit percentage required to execute a trade. Opportunities below this threshold are rejected as `rejected_fees`.", - }, - maxTradeBtc: { - type: "number", - description: "Maximum BTC volume per simulated trade. Caps partial fills.", - }, - staleMs: { - type: "integer", - description: "Quote age (ms) beyond which a price is considered stale and the opportunity is rejected. Read-only (set via env).", - }, - flickerConfirmMs: { - type: "integer", - description: "Minimum time (ms) a spread must persist before execution. Prevents acting on transient latency artefacts.", - }, - latencyMs: { - type: "integer", - description: "Simulated order-routing latency applied when computing exec VWAP drift. Read-only (set via env).", - }, - activeExchanges: { - type: "object", - additionalProperties: { type: "boolean" }, - description: "Map of exchangeId → enabled. Disabled exchanges are excluded from opportunity detection.", - }, - defaults: { - type: "object", - description: "Factory defaults for the mutable fields.", - properties: { - minNetProfitPct: { type: "number" }, - maxTradeBtc: { type: "number" }, - flickerConfirmMs: { type: "integer" }, - activeExchanges: { type: "object", additionalProperties: { type: "boolean" } }, - }, - }, - takerFees: { - type: "object", - additionalProperties: { type: "number" }, - description: "Taker fee rates per exchange (e.g. kraken: 0.0026, bybit: 0.001, okx: 0.001, binance: 0.001).", - }, - withdrawalFeesBtc: { - type: "object", - additionalProperties: { type: "number" }, - description: "BTC withdrawal fees per exchange — only relevant for the rebalancer, never charged per trade.", - }, - }, - }, - - StateSnapshot: { - type: "object", - description: "Full engine snapshot — same payload pushed over SSE on every tick.", - required: ["ts", "quotes", "wallets", "stats", "recentOpportunities", "recentTrades", "rebalances", "pnlSeries", "config"], - properties: { - ts: { type: "integer", description: "Snapshot timestamp (ms epoch)." }, - quotes: { type: "array", items: { $ref: "#/components/schemas/BestQuote" } }, - wallets: { type: "array", items: { $ref: "#/components/schemas/Wallet" } }, - stats: { $ref: "#/components/schemas/EngineStats" }, - recentOpportunities: { type: "array", items: { $ref: "#/components/schemas/Opportunity" } }, - recentTrades: { type: "array", items: { $ref: "#/components/schemas/Trade" } }, - rebalances: { type: "array", items: { $ref: "#/components/schemas/RebalanceEvent" } }, - pnlSeries: { type: "array", items: { $ref: "#/components/schemas/PnlPoint" } }, - config: { $ref: "#/components/schemas/PublicConfig" }, - }, - }, - - SuccessEnvelope: { - type: "object", - required: ["success"], - properties: { - success: { type: "boolean", enum: [true] }, - data: { description: "Response payload (shape depends on the endpoint)." }, - }, - additionalProperties: false, - }, - - SuccessEnvelopeNoData: { - type: "object", - required: ["success"], - properties: { - success: { type: "boolean", enum: [true] }, - }, - additionalProperties: false, - example: { success: true }, - }, - - ErrorEnvelope: { - type: "object", - required: ["success", "error"], - properties: { - success: { type: "boolean", enum: [false] }, - error: { type: "string", description: "Human-readable error message." }, - }, - additionalProperties: false, - example: { success: false, error: "enabled must be a boolean" }, - }, - - DemoControlBody: { - type: "object", - required: ["enabled"], - properties: { - enabled: { - type: "boolean", - description: "`true` to activate the synthetic feed, `false` to return to live data.", - example: true, - }, - }, - additionalProperties: false, - }, - - RecordControlBody: { - type: "object", - required: ["enabled"], - properties: { - enabled: { type: "boolean", description: "Start or stop NDJSON feed recording.", example: false }, - }, - additionalProperties: false, - }, - - ThresholdControlBody: { - type: "object", - required: ["pct"], - properties: { - pct: { - type: "number", - description: "Net profit threshold as a decimal percentage (0.0001–0.01, e.g. `0.0005` = 0.05 %).", - minimum: 0.0001, - maximum: 0.01, - example: 0.0005, - }, - }, - additionalProperties: false, - }, - - MaxTradeControlBody: { - type: "object", - required: ["btc"], - properties: { - btc: { - type: "number", - description: "Maximum BTC volume per trade (0.01–1.0).", - minimum: 0.01, - maximum: 1.0, - example: 0.05, - }, - }, - additionalProperties: false, - }, - - ConfigPatch: { - type: "object", - description: "Partial update for engine configuration. Only provided fields are changed.", - properties: { - minNetProfitPct: { type: "number" }, - maxTradeBtc: { type: "number" }, - flickerConfirmMs: { type: "integer" }, - activeExchanges: { - type: "object", - additionalProperties: { type: "boolean" }, - description: "Partial map — only provided exchange entries are updated.", - }, - }, - }, -}; - -// --------------------------------------------------------------------------- -// Reusable response helpers -// --------------------------------------------------------------------------- - -function successResponseNoData(description: string): OpenAPIV3.ResponseObject { - return { - description, - content: { - "application/json": { - schema: { $ref: "#/components/schemas/SuccessEnvelopeNoData" }, - }, - }, - }; -} - -function successResponse(description: string, dataSchema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, example?: unknown): OpenAPIV3.ResponseObject { - return { - description, - content: { - "application/json": { - schema: { - type: "object", - required: ["success", "data"], - properties: { - success: { type: "boolean", enum: [true] }, - data: dataSchema, - }, - additionalProperties: false, - }, - ...(example !== undefined ? { example } : {}), - }, - }, - }; -} - -const badRequestResponse: OpenAPIV3.ResponseObject = { +const jsonError = { description: "Invalid request body or out-of-range value.", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorEnvelope" }, - }, - }, + content: { "application/json": { schema: ErrorEnvelopeSchema } }, }; -const serverErrorResponse: OpenAPIV3.ResponseObject = { +const jsonServerError = { description: "Unexpected server error.", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorEnvelope" }, - example: { success: false, error: "Internal server error" }, - }, - }, + content: { "application/json": { schema: ErrorEnvelopeSchema } }, }; -function controlBodyResponses( - okDescription: string, - dataSchema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, - example: unknown, -): OpenAPIV3.ResponsesObject { - return { - "200": successResponse(okDescription, dataSchema, example), - "400": badRequestResponse, - "500": serverErrorResponse, - }; +registry.registerPath({ + method: "get", + path: "/api/health", + tags: ["Monitoring"], + summary: "Health check", + description: + "Lightweight endpoint used by Fly.io (and any uptime monitor) to verify the server is alive.", + operationId: "getHealth", + responses: { + 200: { + description: "Server is healthy.", + content: { "application/json": { schema: HealthResponseSchema } }, + }, + 500: jsonServerError, + }, +}); + +registry.registerPath({ + method: "get", + path: "/api/state", + tags: ["Monitoring"], + summary: "Full state snapshot", + description: + "Returns the complete in-memory engine state as a single JSON document.", + operationId: "getState", + responses: { + 200: { + description: "Current engine state.", + content: { "application/json": { schema: StateResponseSchema } }, + }, + 500: jsonServerError, + }, +}); + +registry.registerPath({ + method: "get", + path: "/api/stream", + tags: ["Streaming"], + summary: "SSE real-time feed", + description: + "Opens a persistent Server-Sent Events connection. Each event carries a JSON-encoded StateSnapshot.", + operationId: "getStream", + responses: { + 200: { + description: "SSE stream opened.", + content: { + "text/event-stream": { + schema: { + type: "string", + description: "Newline-delimited SSE events.", + }, + }, + }, + }, + }, +}); + +registry.registerPath({ + method: "get", + path: "/api/config", + tags: ["Configuration"], + summary: "Get engine configuration", + operationId: "getConfig", + responses: { + 200: { + description: "Current configuration.", + content: { "application/json": { schema: ConfigResponseSchema } }, + }, + 500: jsonServerError, + }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/config", + tags: ["Configuration"], + summary: "Update engine configuration", + operationId: "patchConfig", + request: { + body: { + content: { "application/json": { schema: ConfigPatchSchema } }, + required: true, + }, + }, + responses: { + 200: { + description: "Updated configuration.", + content: { "application/json": { schema: ConfigResponseSchema } }, + }, + 400: jsonError, + 500: jsonServerError, + }, +}); + +function registerControlPost( + path: string, + operationId: string, + summary: string, + description: string, +): void { + registry.registerPath({ + method: "post", + path, + tags: ["Control"], + summary, + description, + operationId, + responses: { + 200: { + description: summary, + content: { + "application/json": { schema: SuccessEnvelopeNoDataSchema }, + }, + }, + 500: jsonServerError, + }, + }); } -// --------------------------------------------------------------------------- -// Full OpenAPI document -// --------------------------------------------------------------------------- +registerControlPost( + "/api/control/pause", + "controlPause", + "Pause the arbitrage engine", + "Manually pauses the engine until resume is called.", +); +registerControlPost( + "/api/control/resume", + "controlResume", + "Resume the arbitrage engine", + "Resumes opportunity evaluation after pause or circuit-breaker trip.", +); +registerControlPost( + "/api/control/reset", + "controlReset", + "Reset simulated state", + "Resets wallets, trade history, and cumulative P&L.", +); -export const openapiSpec: OpenAPIV3.Document = { +registry.registerPath({ + method: "post", + path: "/api/control/demo", + tags: ["Control"], + summary: "Enable / disable demo feed", + operationId: "controlDemo", + request: { + body: { + content: { "application/json": { schema: DemoControlBodySchema } }, + required: true, + }, + }, + responses: { + 200: { + description: "Demo mode updated.", + content: { "application/json": { schema: DemoModeResponseSchema } }, + }, + 400: jsonError, + 500: jsonServerError, + }, +}); + +registry.registerPath({ + method: "post", + path: "/api/control/record", + tags: ["Control"], + summary: "Enable / disable feed recording", + operationId: "controlRecord", + request: { + body: { + content: { "application/json": { schema: RecordControlBodySchema } }, + required: true, + }, + }, + responses: { + 200: { + description: "Recording state updated.", + content: { "application/json": { schema: RecordFeedResponseSchema } }, + }, + 400: jsonError, + 500: jsonServerError, + }, +}); + +registry.registerPath({ + method: "post", + path: "/api/control/threshold", + tags: ["Control"], + summary: "Set minimum net profit threshold", + operationId: "controlThreshold", + request: { + body: { + content: { "application/json": { schema: ThresholdControlBodySchema } }, + required: true, + }, + }, + responses: { + 200: { + description: "Threshold updated.", + content: { "application/json": { schema: ThresholdResponseSchema } }, + }, + 400: jsonError, + 500: jsonServerError, + }, +}); + +registry.registerPath({ + method: "post", + path: "/api/control/max-trade", + tags: ["Control"], + summary: "Set maximum trade volume", + operationId: "controlMaxTrade", + request: { + body: { + content: { "application/json": { schema: MaxTradeControlBodySchema } }, + required: true, + }, + }, + responses: { + 200: { + description: "Max trade volume updated.", + content: { "application/json": { schema: MaxTradeResponseSchema } }, + }, + 400: jsonError, + 500: jsonServerError, + }, +}); + +const generator = new OpenApiGeneratorV3(registry.definitions); + +export const openapiDocument = generator.generateDocument({ openapi: "3.0.3", info: { title: "Arb Pulse API", version: "1.0.0", - description: ` -**Arb Pulse** is a real-time BTC/USDT arbitrage detection and simulation engine -that monitors live order books from **Kraken**, **Bybit**, **OKX** and **Binance** simultaneously. - -> ⚠️ **Simulated only** — no real funds are ever committed. All trades are paper-executed -> against live (or synthetic demo) market data. The system uses a *pre-positioned inventory -> model*: each exchange holds both USDT and BTC so buy and sell legs run in parallel without -> on-chain transfers. - -### Key concepts -| Concept | Detail | -|---|---| -| Profit formula | \`sellVwap·vol·(1−feeSell) − buyVwap·vol·(1+feeBuy)\` | -| Slippage | Embedded in VWAP by walking the order book depth level by level | -| Anti-flicker | Spread must persist \`flickerConfirmMs\` before execution | -| Circuit breaker | Auto-pauses after N consecutive losses | -| Demo mode | Injects synthetic divergences — clearly labelled, never presented as real | - `.trim(), + description: + "Real-time BTC/USDT arbitrage detection and simulation engine. Simulated only — no real funds are committed.", contact: { name: "Arb Pulse" }, license: { name: "MIT" }, }, - servers: [ - { url: "/", description: "Current host (Fly.io / local)" }, - ], + servers: [{ url: "/", description: "Current host (Fly.io / local)" }], tags: [ { name: "Monitoring", description: "Health and state inspection." }, { name: "Streaming", description: "Server-Sent Events real-time feed." }, - { name: "Configuration", description: "Read and update engine parameters." }, + { + name: "Configuration", + description: "Read and update engine parameters.", + }, { name: "Control", description: "Pause, resume, reset and mode switches." }, ], - paths: { - "/api/health": { - get: { - tags: ["Monitoring"], - summary: "Health check", - description: - "Lightweight endpoint used by Fly.io (and any uptime monitor) to verify the server is alive. Always returns HTTP 200 while the process is running.", - operationId: "getHealth", - responses: { - "200": successResponse("Server is healthy.", { - type: "object", - required: ["status", "ts"], - properties: { - status: { type: "string", enum: ["ok"] }, - ts: { type: "integer", description: "Server timestamp (ms epoch)." }, - }, - }, { success: true, data: { status: "ok", ts: 1_700_000_000_000 } }), - "500": serverErrorResponse, - }, - }, - }, - - "/api/state": { - get: { - tags: ["Monitoring"], - summary: "Full state snapshot", - description: - "Returns the complete in-memory engine state as a single JSON document: live quotes from all active exchanges, simulated wallet balances, engine statistics, the last N detected opportunities and executed trades, rebalance history, the cumulative P&L time-series, and the current engine configuration.", - operationId: "getState", - responses: { - "200": successResponse("Current engine state.", { $ref: "#/components/schemas/StateSnapshot" }), - "500": serverErrorResponse, - }, - }, - }, - - "/api/stream": { - get: { - tags: ["Streaming"], - summary: "SSE real-time feed", - description: - "Opens a persistent [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) connection. The server pushes a `StateSnapshot` JSON payload on every engine tick (typically every few hundred milliseconds). The dashboard React app subscribes here — no WebSocket or polling needed.\n\nConnect with `EventSource` or any SSE client. The stream never closes unless the server restarts or the client disconnects.", - operationId: "getStream", - responses: { - "200": { - description: "SSE stream opened. Each `data:` event carries a JSON-encoded `StateSnapshot`.", - content: { - "text/event-stream": { - schema: { - type: "string", - description: "Newline-delimited SSE events. Each `data:` line contains a serialized `StateSnapshot`.", - }, - }, - }, - }, - }, - }, - }, - - "/api/config": { - get: { - tags: ["Configuration"], - summary: "Get engine configuration", - description: - "Returns the current mutable and immutable engine parameters: profit threshold, max trade size, flicker window, active exchanges, fee tables and factory defaults.", - operationId: "getConfig", - responses: { - "200": successResponse("Current configuration.", { $ref: "#/components/schemas/PublicConfig" }), - "500": serverErrorResponse, - }, - }, - patch: { - tags: ["Configuration"], - summary: "Update engine configuration", - description: - "Applies a partial update to mutable engine parameters. Only the fields present in the request body are changed; omitted fields retain their current values.\n\nReturns the full updated configuration on success.", - operationId: "patchConfig", - requestBody: { - required: true, - content: { - "application/json": { schema: { $ref: "#/components/schemas/ConfigPatch" } }, - }, - }, - responses: { - "200": successResponse("Updated configuration.", { $ref: "#/components/schemas/PublicConfig" }), - "400": badRequestResponse, - "500": serverErrorResponse, - }, - }, - }, - - "/api/control/pause": { - post: { - tags: ["Control"], - summary: "Pause the arbitrage engine", - description: - "Manually pauses the engine. Opportunities are no longer evaluated and no new trades are executed until `resume` is called. Order book feeds remain connected. This is equivalent to setting the circuit state to `paused`.", - operationId: "controlPause", - responses: { - "200": successResponseNoData("Engine paused."), - "500": serverErrorResponse, - }, - }, - }, - - "/api/control/resume": { - post: { - tags: ["Control"], - summary: "Resume the arbitrage engine", - description: - "Resumes opportunity evaluation after a `pause` or a circuit-breaker trip. The engine immediately re-enters the `running` circuit state.", - operationId: "controlResume", - responses: { - "200": successResponseNoData("Engine resumed."), - "500": serverErrorResponse, - }, - }, - }, - - "/api/control/reset": { - post: { - tags: ["Control"], - summary: "Reset simulated state", - description: - "Resets the simulated wallets back to their initial pre-positioned balances, clears the trade history, resets the cumulative P&L to zero, and restores the consecutive-loss counter. Engine configuration (thresholds, fee tables) is not affected. Useful for starting a clean simulation session.", - operationId: "controlReset", - responses: { - "200": successResponseNoData("State reset."), - "500": serverErrorResponse, - }, - }, - }, - - "/api/control/demo": { - post: { - tags: ["Control"], - summary: "Enable / disable demo feed", - description: - "Switches between the live exchange feeds and the **synthetic demo injector**. When `enabled: true` the engine receives artificially inflated spreads that guarantee visible arbitrage opportunities — clearly labelled with `demo: true` in every event so they are never mistaken for real market signals.\n\n**Note:** Real feeds are paused while demo mode is active.", - operationId: "controlDemo", - requestBody: { - required: true, - content: { - "application/json": { schema: { $ref: "#/components/schemas/DemoControlBody" } }, - }, - }, - responses: controlBodyResponses( - "Demo mode updated.", - { - type: "object", - required: ["demoMode"], - properties: { demoMode: { type: "boolean" } }, - additionalProperties: false, - }, - { success: true, data: { demoMode: true } }, - ), - }, - }, - - "/api/control/record": { - post: { - tags: ["Control"], - summary: "Enable / disable feed recording", - description: - "Starts or stops recording live order-book events to an NDJSON file on disk for later replay. Useful for capturing real market sessions to run deterministic back-tests without re-connecting to exchanges.", - operationId: "controlRecord", - requestBody: { - required: true, - content: { - "application/json": { schema: { $ref: "#/components/schemas/RecordControlBody" } }, - }, - }, - responses: controlBodyResponses( - "Recording state updated.", - { - type: "object", - required: ["recordFeed"], - properties: { recordFeed: { type: "boolean" } }, - additionalProperties: false, - }, - { success: true, data: { recordFeed: false } }, - ), - }, - }, - - "/api/control/threshold": { - post: { - tags: ["Control"], - summary: "Set minimum net profit threshold", - description: - "Adjusts the minimum net profit percentage an opportunity must exceed to be executed. Lower values detect more opportunities but increase the chance of tiny losses due to fee rounding. Opportunities below this threshold are logged as `rejected_fees`.\n\nEquivalent to `PATCH /api/config` with `{ minNetProfitPct }` but provided as a convenience endpoint.", - operationId: "controlThreshold", - requestBody: { - required: true, - content: { - "application/json": { schema: { $ref: "#/components/schemas/ThresholdControlBody" } }, - }, - }, - responses: controlBodyResponses( - "Threshold updated.", - { - type: "object", - required: ["minNetProfitPct"], - properties: { minNetProfitPct: { type: "number" } }, - additionalProperties: false, - }, - { success: true, data: { minNetProfitPct: 0.0005 } }, - ), - }, - }, - - "/api/control/max-trade": { - post: { - tags: ["Control"], - summary: "Set maximum trade volume", - description: - "Sets the upper bound on BTC volume per simulated trade. The engine also caps volume by available order-book liquidity and wallet inventory — this parameter acts as an additional hard cap.\n\nEquivalent to `PATCH /api/config` with `{ maxTradeBtc }` but provided as a convenience endpoint.", - operationId: "controlMaxTrade", - requestBody: { - required: true, - content: { - "application/json": { schema: { $ref: "#/components/schemas/MaxTradeControlBody" } }, - }, - }, - responses: controlBodyResponses( - "Max trade volume updated.", - { - type: "object", - required: ["maxTradeBtc"], - properties: { maxTradeBtc: { type: "number" } }, - additionalProperties: false, - }, - { success: true, data: { maxTradeBtc: 0.05 } }, - ), - }, - }, - }, - - components: { - schemas, - responses: { - BadRequest: badRequestResponse, - ServerError: serverErrorResponse, - }, - }, -}; +}); diff --git a/src/interfaces/http/routes.ts b/src/interfaces/http/routes.ts index b5983a1..805b23a 100644 --- a/src/interfaces/http/routes.ts +++ b/src/interfaces/http/routes.ts @@ -1,6 +1,14 @@ import { Router, type Request, type Response } from "express"; import type { ApplicationService } from "../../composition/application-service.js"; import type { SseHub } from "../sse/sse.js"; +import { + ConfigPatchSchema, + DemoControlBodySchema, + MaxTradeControlBodySchema, + RecordControlBodySchema, + ThresholdControlBodySchema, +} from "./schemas/requests.js"; +import { parseBody } from "./validate.js"; export function createRouter(app: ApplicationService, sse: SseHub): Router { const router = Router(); @@ -22,7 +30,10 @@ export function createRouter(app: ApplicationService, sse: SseHub): Router { }); router.patch("/config", (req: Request, res: Response) => { - const error = app.patchConfig(req.body ?? {}); + const patch = parseBody(ConfigPatchSchema, req, res); + if (patch === null) return; + + const error = app.patchConfig(patch); if (error) { res.status(400).json({ success: false, error }); return; @@ -46,51 +57,43 @@ export function createRouter(app: ApplicationService, sse: SseHub): Router { }); router.post("/control/demo", (req: Request, res: Response) => { - const enabled = req.body?.enabled; - if (typeof enabled !== "boolean") { - res.status(400).json({ success: false, error: "enabled must be a boolean" }); - return; - } - app.setDemoMode(enabled); - res.json({ success: true, data: { demoMode: enabled } }); + const body = parseBody(DemoControlBodySchema, req, res); + if (body === null) return; + + app.setDemoMode(body.enabled); + res.json({ success: true, data: { demoMode: body.enabled } }); }); router.post("/control/record", (req: Request, res: Response) => { - const enabled = req.body?.enabled; - if (typeof enabled !== "boolean") { - res.status(400).json({ success: false, error: "enabled must be a boolean" }); - return; - } - app.setRecordFeed(enabled); - res.json({ success: true, data: { recordFeed: enabled } }); + const body = parseBody(RecordControlBodySchema, req, res); + if (body === null) return; + + app.setRecordFeed(body.enabled); + res.json({ success: true, data: { recordFeed: body.enabled } }); }); router.post("/control/threshold", (req: Request, res: Response) => { - const pct = req.body?.pct; - if (typeof pct !== "number" || !Number.isFinite(pct)) { - res.status(400).json({ success: false, error: "pct must be a finite number" }); - return; - } - const error = app.setThreshold(pct); + const body = parseBody(ThresholdControlBodySchema, req, res); + if (body === null) return; + + const error = app.setThreshold(body.pct); if (error) { res.status(400).json({ success: false, error }); return; } - res.json({ success: true, data: { minNetProfitPct: pct } }); + res.json({ success: true, data: { minNetProfitPct: body.pct } }); }); router.post("/control/max-trade", (req: Request, res: Response) => { - const btc = req.body?.btc; - if (typeof btc !== "number" || !Number.isFinite(btc)) { - res.status(400).json({ success: false, error: "btc must be a finite number" }); - return; - } - const error = app.setMaxTradeBtc(btc); + const body = parseBody(MaxTradeControlBodySchema, req, res); + if (body === null) return; + + const error = app.setMaxTradeBtc(body.btc); if (error) { res.status(400).json({ success: false, error }); return; } - res.json({ success: true, data: { maxTradeBtc: btc } }); + res.json({ success: true, data: { maxTradeBtc: body.btc } }); }); return router; diff --git a/src/interfaces/http/schemas/common.ts b/src/interfaces/http/schemas/common.ts new file mode 100644 index 0000000..811fd87 --- /dev/null +++ b/src/interfaces/http/schemas/common.ts @@ -0,0 +1,59 @@ +import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; +import { z } from "zod"; + +extendZodWithOpenApi(z); + +export { z }; + +export const ExchangeIdSchema = z + .enum(["kraken", "bybit", "okx", "binance"]) + .openapi("ExchangeId"); + +export const FeedStatusSchema = z + .enum(["connecting", "live", "stale", "down"]) + .openapi("FeedStatus"); + +export const CircuitStateSchema = z + .enum(["running", "paused", "tripped"]) + .openapi("CircuitState"); + +export const OpportunityStatusSchema = z + .enum([ + "executed", + "executed_partial", + "rejected_fees", + "rejected_liquidity", + "rejected_risk", + "rejected_flicker", + "rejected_stale", + "pending_confirm", + ]) + .openapi("OpportunityStatus"); + +export const ErrorEnvelopeSchema = z + .object({ + success: z.literal(false), + error: z.string(), + }) + .strict() + .openapi("ErrorEnvelope"); + +export const SuccessEnvelopeNoDataSchema = z + .object({ + success: z.literal(true), + }) + .strict() + .openapi("SuccessEnvelopeNoData"); + +export function successEnvelope( + dataSchema: T, + name: string, +) { + return z + .object({ + success: z.literal(true), + data: dataSchema, + }) + .strict() + .openapi(name); +} diff --git a/src/interfaces/http/schemas/domain.ts b/src/interfaces/http/schemas/domain.ts new file mode 100644 index 0000000..c663ac5 --- /dev/null +++ b/src/interfaces/http/schemas/domain.ts @@ -0,0 +1,161 @@ +import { + CircuitStateSchema, + ExchangeIdSchema, + FeedStatusSchema, + OpportunityStatusSchema, + z, +} from "./common.js"; + +export const BestQuoteSchema = z + .object({ + exchange: ExchangeIdSchema, + bid: z.number().nullable(), + bidQty: z.number().nullable(), + ask: z.number().nullable(), + askQty: z.number().nullable(), + recvTs: z.number().int().nullable(), + status: FeedStatusSchema, + ageMs: z.number().int().nullable(), + }) + .strict() + .openapi("BestQuote"); + +export const WalletSchema = z + .object({ + exchange: ExchangeIdSchema, + usdt: z.number(), + btc: z.number(), + }) + .strict() + .openapi("Wallet"); + +export const OpportunitySchema = z + .object({ + id: z.string(), + ts: z.number().int(), + buyExchange: ExchangeIdSchema, + sellExchange: ExchangeIdSchema, + topBuyAsk: z.number(), + topSellBid: z.number(), + volumeBtc: z.number(), + buyVwap: z.number(), + sellVwap: z.number(), + grossSpread: z.number(), + grossSpreadPct: z.number(), + feeBuy: z.number(), + feeSell: z.number(), + netProfit: z.number(), + netProfitPct: z.number(), + status: OpportunityStatusSchema, + reason: z.string(), + demo: z.boolean(), + }) + .strict() + .openapi("Opportunity"); + +export const TradeSchema = z + .object({ + id: z.string(), + ts: z.number().int(), + buyExchange: ExchangeIdSchema, + sellExchange: ExchangeIdSchema, + volumeBtc: z.number(), + requestedBtc: z.number(), + buyVwap: z.number(), + sellVwap: z.number(), + execBuyVwap: z.number(), + execSellVwap: z.number(), + feeBuy: z.number(), + feeSell: z.number(), + netProfit: z.number(), + netProfitPct: z.number(), + partial: z.boolean(), + demo: z.boolean(), + }) + .strict() + .openapi("Trade"); + +export const RebalanceEventSchema = z + .object({ + id: z.string(), + ts: z.number().int(), + fromExchange: ExchangeIdSchema, + toExchange: ExchangeIdSchema, + asset: z.enum(["BTC", "USDT"]), + amount: z.number(), + withdrawalFee: z.number(), + reason: z.string(), + }) + .strict() + .openapi("RebalanceEvent"); + +export const PnlPointSchema = z + .object({ + ts: z.number().int(), + pnl: z.number(), + }) + .strict() + .openapi("PnlPoint"); + +export const EngineStatsSchema = z + .object({ + uptimeMs: z.number().int(), + ticksProcessed: z.number().int(), + opportunitiesDetected: z.number().int(), + tradesExecuted: z.number().int(), + tradesRejected: z.number().int(), + realizedPnl: z.number(), + consecutiveLosses: z.number().int(), + circuit: CircuitStateSchema, + demoMode: z.boolean(), + avgTickMs: z.number(), + }) + .strict() + .openapi("EngineStats"); + +const ActiveExchangesSchema = z.record(ExchangeIdSchema, z.boolean()); + +export const PublicConfigSchema = z + .object({ + minNetProfitPct: z.number(), + maxTradeBtc: z.number(), + staleMs: z.number().int(), + flickerConfirmMs: z.number().int(), + latencyMs: z.number().int(), + activeExchanges: ActiveExchangesSchema, + defaults: z + .object({ + minNetProfitPct: z.number(), + maxTradeBtc: z.number(), + flickerConfirmMs: z.number().int(), + activeExchanges: ActiveExchangesSchema, + }) + .strict(), + takerFees: z.record(ExchangeIdSchema, z.number()), + withdrawalFeesBtc: z.record(ExchangeIdSchema, z.number()), + }) + .strict() + .openapi("PublicConfig"); + +export const StateSnapshotSchema = z + .object({ + ts: z.number().int(), + quotes: z.array(BestQuoteSchema), + wallets: z.array(WalletSchema), + stats: EngineStatsSchema, + recentOpportunities: z.array(OpportunitySchema), + recentTrades: z.array(TradeSchema), + rebalances: z.array(RebalanceEventSchema), + pnlSeries: z.array(PnlPointSchema), + config: PublicConfigSchema, + }) + .strict() + .openapi("StateSnapshot"); + +export const HealthDataSchema = z + .object({ + status: z.literal("ok"), + ts: z.number().int(), + }) + .strict() + .openapi("HealthData"); diff --git a/src/interfaces/http/schemas/requests.ts b/src/interfaces/http/schemas/requests.ts new file mode 100644 index 0000000..f767de8 --- /dev/null +++ b/src/interfaces/http/schemas/requests.ts @@ -0,0 +1,53 @@ +import { ExchangeIdSchema, z } from "./common.js"; + +const MIN_PROFIT_PCT = 0.0001; +const MAX_PROFIT_PCT = 0.01; +const MIN_TRADE_BTC = 0.01; +const MAX_TRADE_BTC = 1.0; +const MAX_FLICKER_MS = 500; + +export const BooleanControlBodySchema = z + .object({ + enabled: z.boolean(), + }) + .strict() + .openapi("BooleanControlBody"); + +export const DemoControlBodySchema = + BooleanControlBodySchema.openapi("DemoControlBody"); +export const RecordControlBodySchema = + BooleanControlBodySchema.openapi("RecordControlBody"); + +export const ThresholdControlBodySchema = z + .object({ + pct: z.number().finite().min(MIN_PROFIT_PCT).max(MAX_PROFIT_PCT), + }) + .strict() + .openapi("ThresholdControlBody"); + +export const MaxTradeControlBodySchema = z + .object({ + btc: z.number().finite().min(MIN_TRADE_BTC).max(MAX_TRADE_BTC), + }) + .strict() + .openapi("MaxTradeControlBody"); + +export const ConfigPatchSchema = z + .object({ + minNetProfitPct: z + .number() + .finite() + .min(MIN_PROFIT_PCT) + .max(MAX_PROFIT_PCT) + .optional(), + maxTradeBtc: z + .number() + .finite() + .min(MIN_TRADE_BTC) + .max(MAX_TRADE_BTC) + .optional(), + flickerConfirmMs: z.number().int().min(0).max(MAX_FLICKER_MS).optional(), + activeExchanges: z.record(ExchangeIdSchema, z.boolean()).optional(), + }) + .strict() + .openapi("ConfigPatch"); diff --git a/src/interfaces/http/schemas/responses.ts b/src/interfaces/http/schemas/responses.ts new file mode 100644 index 0000000..ffc3eef --- /dev/null +++ b/src/interfaces/http/schemas/responses.ts @@ -0,0 +1,42 @@ +import { successEnvelope, SuccessEnvelopeNoDataSchema } from "./common.js"; +import { + HealthDataSchema, + PublicConfigSchema, + StateSnapshotSchema, +} from "./domain.js"; +import { z } from "./common.js"; + +export const HealthResponseSchema = successEnvelope( + HealthDataSchema, + "HealthResponse", +); +export const StateResponseSchema = successEnvelope( + StateSnapshotSchema, + "StateResponse", +); +export const ConfigResponseSchema = successEnvelope( + PublicConfigSchema, + "ConfigResponse", +); + +export const DemoModeResponseSchema = successEnvelope( + z.object({ demoMode: z.boolean() }).strict(), + "DemoModeResponse", +); + +export const RecordFeedResponseSchema = successEnvelope( + z.object({ recordFeed: z.boolean() }).strict(), + "RecordFeedResponse", +); + +export const ThresholdResponseSchema = successEnvelope( + z.object({ minNetProfitPct: z.number() }).strict(), + "ThresholdResponse", +); + +export const MaxTradeResponseSchema = successEnvelope( + z.object({ maxTradeBtc: z.number() }).strict(), + "MaxTradeResponse", +); + +export { SuccessEnvelopeNoDataSchema }; diff --git a/src/interfaces/http/validate.ts b/src/interfaces/http/validate.ts new file mode 100644 index 0000000..6cb1334 --- /dev/null +++ b/src/interfaces/http/validate.ts @@ -0,0 +1,18 @@ +import type { Request, Response } from "express"; +import type { ZodType } from "zod"; + +export function parseBody( + schema: ZodType, + req: Request, + res: Response, +): T | null { + const result = schema.safeParse(req.body ?? {}); + if (!result.success) { + const message = result.error.issues + .map((issue) => issue.message) + .join("; "); + res.status(400).json({ success: false, error: message }); + return null; + } + return result.data; +} diff --git a/web/package-lock.json b/web/package-lock.json index 8729b87..38deb77 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -27,18 +27,26 @@ "name": "arb-pulse", "version": "1.0.0", "dependencies": { + "@asteasolutions/zod-to-openapi": "^8.5.0", + "@scalar/express-api-reference": "^0.8.48", "express": "^4.21.2", - "swagger-ui-express": "^5.0.1", "tsx": "^4.19.2", - "ws": "^8.18.0" + "ws": "^8.18.0", + "zod": "^4.4.3" }, "devDependencies": { + "@eslint/js": "^9.18.0", "@types/express": "^4.17.21", "@types/node": "^22.10.5", - "@types/swagger-ui-express": "^4.1.8", "@types/ws": "^8.5.13", - "openapi-types": "^12.1.3", - "typescript": "^5.7.3" + "eslint": "^9.18.0", + "eslint-plugin-react-hooks": "^5.1.0", + "globals": "^15.14.0", + "husky": "^9.1.7", + "lint-staged": "^16.4.0", + "prettier": "^3.8.3", + "typescript": "^5.7.3", + "typescript-eslint": "^8.26.0" }, "engines": { "node": ">=20" From fd4170963e1ce7301ed72599980997c9a6e935ea Mon Sep 17 00:00:00 2001 From: Mauricio Barragan Date: Thu, 11 Jun 2026 01:01:20 -0600 Subject: [PATCH 3/7] Add Sentry, Pino logs, and OTel spans on WS pipeline (M2). Capture REST/WS/SSE errors in Sentry, emit structured pino logs with correlation IDs, and trace the book-tick pipeline to Sentry via OpenTelemetry. Co-authored-by: Cursor --- .env.example | 8 +- .github/dependabot.yml | 18 + .github/workflows/codeql.yml | 30 + .github/workflows/e2e.yml | 45 ++ README.md | 5 +- e2e/smoke.spec.ts | 19 + package-lock.json | 679 +++++++++++++++++- package.json | 10 + playwright.config.ts | 24 + .../use-cases/process-order-book-update.ts | 29 +- src/index.ts | 6 + src/infrastructure/cache/upstash.ts | 48 ++ src/infrastructure/exchanges/base.ts | 43 +- src/infrastructure/logging/correlation.ts | 39 + src/infrastructure/logging/logger.ts | 46 +- src/instrumentation/index.ts | 7 + src/instrumentation/otel.ts | 52 ++ src/instrumentation/sentry.ts | 26 + src/interfaces/http/rate-limit.ts | 86 +++ src/interfaces/http/routes.ts | 36 +- src/interfaces/sse/sse.ts | 65 +- web/package-lock.json | 9 + 22 files changed, 1280 insertions(+), 50 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/e2e.yml create mode 100644 e2e/smoke.spec.ts create mode 100644 playwright.config.ts create mode 100644 src/infrastructure/cache/upstash.ts create mode 100644 src/infrastructure/logging/correlation.ts create mode 100644 src/instrumentation/index.ts create mode 100644 src/instrumentation/otel.ts create mode 100644 src/instrumentation/sentry.ts create mode 100644 src/interfaces/http/rate-limit.ts diff --git a/.env.example b/.env.example index 394755a..ac3f910 100644 --- a/.env.example +++ b/.env.example @@ -25,5 +25,11 @@ RECORD_FEED=false # API docs — Scalar at GET /api-docs (OpenAPI generated from Zod; no env vars required) -# Sentry — https://sentry.io (project: arbpulse) +# Observability — https://sentry.io (project: arbpulse) SENTRY_DSN= +# Structured logs (pino); correlation IDs on REST, SSE, and WS hot path +LOG_LEVEL=info + +# Upstash Redis — rate limit on /api/* and short-TTL snapshot cache for GET /api/state +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0b80086 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: npm + directory: "/web" + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..dfc9278 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,30 @@ +name: CodeQL Security Scan + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + schedule: + - cron: "0 8 * * 1" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + steps: + - uses: actions/checkout@v4 + + - uses: github/codeql-action/init@v3 + with: + languages: javascript-typescript + queries: security-extended + + - uses: github/codeql-action/autobuild@v3 + + - uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..652f47e --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,45 @@ +name: E2E Smoke + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +jobs: + smoke: + name: Playwright dashboard + SSE + runs-on: ubuntu-latest + env: + DEMO_MODE: "true" + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: | + package-lock.json + web/package-lock.json + + - run: npm ci + - run: npm --prefix web ci + - run: npm run build + + - name: Start server + run: npm start & + + - name: Wait for API + run: | + for i in $(seq 1 45); do + curl -sf http://localhost:8080/api/health > /dev/null && echo "Ready after ${i}s" && exit 0 + sleep 1 + done + echo "::error::Server did not become ready in 45s" && exit 1 + + - run: npx playwright install --with-deps chromium + + - name: Run smoke tests + run: npm run test:e2e diff --git a/README.md b/README.md index a52c219..14d70a5 100644 --- a/README.md +++ b/README.md @@ -347,7 +347,10 @@ Eso no indica que el bot “no funcione”: indica que el filtro económico es e - **Pre-commit:** Husky runs lint-staged (`eslint --fix`, `prettier --write`) on staged `*.ts` / `*.tsx` in `src/` and `web/src/`. - **API contracts:** Zod schemas per REST endpoint → OpenAPI via `@asteasolutions/zod-to-openapi` → Scalar UI at `/api-docs`. Request bodies validated with Zod in route handlers. -- **Observability:** Sentry planned — set `SENTRY_DSN` in `.env` after creating the `arbpulse` project in Sentry. +- **Observability:** `@sentry/node` captures REST, WebSocket feed, and SSE errors; `pino` JSON logs with `correlationId` (from `x-request-id` or per book tick); OpenTelemetry spans (`ws.message` → `orderbook.process` → `arbitrage.evaluate` → `sse.broadcast`) export to Sentry via `@sentry/opentelemetry` when `SENTRY_DSN` is set. +- **Rate limiting & cache:** Upstash sliding-window limits per IP on read/write/SSE routes (`429` + `Retry-After`). Latest `StateSnapshot` cached in Redis (~1s TTL) for `GET /api/state` and refreshed on SSE broadcast. Set `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN`. +- **CI:** GitHub Actions quality pipeline (typecheck, unit tests, build); Playwright smoke on PRs — dashboard loads and SSE connects (`npm run test:e2e`, workflow `e2e.yml`, `DEMO_MODE=true` in CI). +- **Security scanning:** CodeQL (`.github/workflows/codeql.yml`); Dependabot for npm (root + `web/`) and GitHub Actions. --- diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..dad7834 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from "@playwright/test"; + +test("dashboard loads", async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("heading", { name: /ArbPulse/i })).toBeVisible(); +}); + +test("SSE stream connects and delivers engine state", async ({ page, request }) => { + const health = await request.get("/api/health"); + expect(health.ok()).toBeTruthy(); + + await page.goto("/"); + await expect(page.getByText("Connecting to engine…")).toBeHidden({ + timeout: 45_000, + }); + await expect(page.getByText("LIVE", { exact: true })).toBeVisible(); + await expect(page.getByText("RUNNING")).toBeVisible(); + await expect(page.getByText("Realized P&L")).toBeVisible(); +}); diff --git a/package-lock.json b/package-lock.json index 631a0fa..97bc9bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,23 @@ "version": "1.0.0", "dependencies": { "@asteasolutions/zod-to-openapi": "^8.5.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/sdk-trace-node": "^2.7.1", "@scalar/express-api-reference": "^0.8.48", + "@sentry/node": "^10.57.0", + "@sentry/opentelemetry": "^10.57.0", + "@upstash/ratelimit": "^2.0.6", + "@upstash/redis": "^1.35.3", "express": "^4.21.2", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", "tsx": "^4.19.2", "ws": "^8.18.0", "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^9.18.0", + "@playwright/test": "^1.51.1", "@types/express": "^4.17.21", "@types/node": "^22.10.5", "@types/ws": "^8.5.13", @@ -734,6 +743,152 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", + "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.7.1.tgz", + "integrity": "sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz", + "integrity": "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.214.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", + "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.7.1.tgz", + "integrity": "sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.7.1", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@playwright/test": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.51.1.tgz", + "integrity": "sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.51.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@scalar/core": { "version": "0.3.45", "resolved": "https://registry.npmjs.org/@scalar/core/-/core-0.3.45.tgz", @@ -782,6 +937,108 @@ "node": ">=20" } }, + "node_modules/@sentry-internal/server-utils": { + "version": "10.57.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/server-utils/-/server-utils-10.57.0.tgz", + "integrity": "sha512-Qu8ETmX/ITzteG7Im46b9HOxKKzeaIeqNvftaIlFURu1RUQdHbtGerS7QOmXzwnhuqNGNeiCQYkduB798IfRqA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.57.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/core": { + "version": "10.57.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.57.0.tgz", + "integrity": "sha512-kntItTA2kiT0YpL7encXaF6mkdZMB+y48lwj8w1wkfBpfJAC7sifdgrzLQZqmsqVNE3crg9VfufaAGA+78uFMg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node": { + "version": "10.57.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.57.0.tgz", + "integrity": "sha512-7KEStrJ97wPf1fA5nU5ONeTTcIIlh7oT8OMffEVA1PXmlhFoXhcQZVzr4rM+zj9tfMWT01og5Ng/Grgh3dN+FA==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/core": "^2.6.1", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/sdk-trace-base": "^2.6.1", + "@opentelemetry/semantic-conventions": "^1.40.0", + "@sentry-internal/server-utils": "10.57.0", + "@sentry/core": "10.57.0", + "@sentry/node-core": "10.57.0", + "@sentry/opentelemetry": "10.57.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.57.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.57.0.tgz", + "integrity": "sha512-2v2IF6MfTiu7pimWEq2rYhZsmlwyNbs3bHUsrYFPeP/Rpa6ObDuUWPdVEzJjfyK+AqqYZYxZdV0l3+B13kTEmQ==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.57.0", + "@sentry/opentelemetry": "10.57.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/semantic-conventions": "^1.39.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "@opentelemetry/semantic-conventions": { + "optional": true + } + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "10.57.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.57.0.tgz", + "integrity": "sha512-iwRz8cEK0GOISG34aJRO8GdYOk3nfpuT6dT2GDQrxw8f7JjkJKx9LPU8MaenOFa4MhY+Z02hI6NNcrbsoI3cXg==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.57.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/semantic-conventions": "^1.39.0" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1306,6 +1563,39 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@upstash/core-analytics": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@upstash/core-analytics/-/core-analytics-0.0.10.tgz", + "integrity": "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==", + "license": "MIT", + "dependencies": { + "@upstash/redis": "^1.28.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@upstash/ratelimit": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@upstash/ratelimit/-/ratelimit-2.0.6.tgz", + "integrity": "sha512-Uak5qklMfzFN5RXltxY6IXRENu+Hgmo9iEgMPOlUs2etSQas2N+hJfbHw37OUy4vldLRXeD0OzL+YRvO2l5acg==", + "license": "MIT", + "dependencies": { + "@upstash/core-analytics": "^0.0.10" + }, + "peerDependencies": { + "@upstash/redis": "^1.34.3" + } + }, + "node_modules/@upstash/redis": { + "version": "1.35.3", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.35.3.tgz", + "integrity": "sha512-hSjv66NOuahW3MisRGlSgoszU2uONAY2l5Qo3Sae8OT3/Tng9K+2/cBRuyPBX8egwEGcNNCF9+r0V6grNnhL+w==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1323,7 +1613,6 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1332,6 +1621,15 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -1417,6 +1715,15 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1524,6 +1831,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "license": "MIT" + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -1581,7 +1894,6 @@ "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, "license": "MIT" }, "node_modules/commander": { @@ -1652,6 +1964,15 @@ "node": ">= 8" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1723,6 +2044,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -2080,6 +2410,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-copy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz", + "integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2101,6 +2437,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2351,6 +2693,12 @@ "node": ">= 0.4" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2426,6 +2774,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-in-the-middle": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.2.tgz", + "integrity": "sha512-LGLYRl0A2gtyUJb2WDliBHmk6TtlHwdDjxonacZ8QrEs/ZW+YDgNv2QAfjRQWpS8HqvNcq6GGnN6jrOa5FysDQ==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.15.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2497,6 +2860,15 @@ "dev": true, "license": "ISC" }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/js-yaml": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", @@ -2775,6 +3147,21 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -2827,6 +3214,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2839,6 +3235,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -2975,6 +3380,126 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/playwright": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.1.tgz", + "integrity": "sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.51.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.1.tgz", + "integrity": "sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3001,6 +3526,22 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -3014,6 +3555,16 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3039,6 +3590,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -3063,6 +3620,51 @@ "node": ">= 0.8" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, + "node_modules/require-in-the-middle/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/require-in-the-middle/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3117,12 +3719,37 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", @@ -3325,6 +3952,24 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3415,6 +4060,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", @@ -3561,6 +4224,12 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -3680,6 +4349,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", diff --git a/package.json b/package.json index 297d2d6..5cc4021 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build": "npm --prefix web install && npm --prefix web run build", "typecheck": "tsc --noEmit && npm --prefix web run typecheck", "test": "node scripts/run-tests.mjs", + "test:e2e": "playwright test", "lint": "eslint .", "prepare": "husky" }, @@ -28,14 +29,23 @@ }, "dependencies": { "@asteasolutions/zod-to-openapi": "^8.5.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/sdk-trace-node": "^2.7.1", "@scalar/express-api-reference": "^0.8.48", + "@sentry/node": "^10.57.0", + "@sentry/opentelemetry": "^10.57.0", + "@upstash/ratelimit": "^2.0.6", + "@upstash/redis": "^1.35.3", "express": "^4.21.2", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", "tsx": "^4.19.2", "ws": "^8.18.0", "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^9.18.0", + "@playwright/test": "^1.51.1", "@types/express": "^4.17.21", "@types/node": "^22.10.5", "@types/ws": "^8.5.13", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..13fe249 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "e2e", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + timeout: 60_000, + reporter: process.env.CI ? "github" : "html", + use: { + baseURL: "http://localhost:8080", + trace: "on-first-retry", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + ...(!process.env.CI && { + webServer: { + command: "npm start", + url: "http://localhost:8080/api/health", + reuseExistingServer: !process.env.PW_FRESH_SERVER, + env: { DEMO_MODE: "true" }, + }, + }), +}); diff --git a/src/application/use-cases/process-order-book-update.ts b/src/application/use-cases/process-order-book-update.ts index d4e1e14..f2279d7 100644 --- a/src/application/use-cases/process-order-book-update.ts +++ b/src/application/use-cases/process-order-book-update.ts @@ -1,5 +1,10 @@ import type { OrderBook } from "../../domain/entities/index.js"; import type { ArbitrageEngine } from "../../domain/services/arbitrage-engine.js"; +import { withSpan } from "../../instrumentation/otel.js"; +import { + newCorrelationId, + runWithCorrelation, +} from "../../infrastructure/logging/correlation.js"; export interface FeedRecorderPort { record(book: OrderBook): void; @@ -10,12 +15,30 @@ export class ProcessOrderBookUpdate { constructor( private readonly engine: ArbitrageEngine, private readonly recorder: FeedRecorderPort, - private readonly isExchangeActive: (exchange: OrderBook["exchange"]) => boolean, + private readonly isExchangeActive: ( + exchange: OrderBook["exchange"], + ) => boolean, ) {} run(book: OrderBook): void { if (!this.isExchangeActive(book.exchange)) return; - this.recorder.record(book); - this.engine.onBook(book); + + const correlationId = newCorrelationId("book"); + runWithCorrelation({ correlationId, exchange: book.exchange }, () => { + void withSpan( + "orderbook.process", + { exchange: book.exchange, correlationId }, + async () => { + this.recorder.record(book); + await withSpan( + "arbitrage.evaluate", + { exchange: book.exchange, correlationId }, + async () => { + this.engine.onBook(book); + }, + ); + }, + ); + }); } } diff --git a/src/index.ts b/src/index.ts index 1c5a367..926a3fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,9 @@ +import { initInstrumentation } from "./instrumentation/index.js"; + +initInstrumentation(); + import express from "express"; +import * as Sentry from "@sentry/node"; import { apiReference } from "@scalar/express-api-reference"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -25,6 +30,7 @@ function main(): void { const server = express(); server.use(express.json()); server.use("/api", createRouter(ctx.application, sse)); + Sentry.setupExpressErrorHandler(server); server.use( "/api-docs", diff --git a/src/infrastructure/cache/upstash.ts b/src/infrastructure/cache/upstash.ts new file mode 100644 index 0000000..c573eb1 --- /dev/null +++ b/src/infrastructure/cache/upstash.ts @@ -0,0 +1,48 @@ +import { Redis } from "@upstash/redis"; + +const SNAPSHOT_KEY = "arbpulse:snapshot"; +const SNAPSHOT_TTL_SEC = 1; + +let redis: Redis | null | undefined; + +export function isUpstashConfigured(): boolean { + return Boolean( + process.env.UPSTASH_REDIS_REST_URL?.trim() && + process.env.UPSTASH_REDIS_REST_TOKEN?.trim(), + ); +} + +function getRedis(): Redis | null { + if (redis !== undefined) { + return redis; + } + const url = process.env.UPSTASH_REDIS_REST_URL?.trim(); + const token = process.env.UPSTASH_REDIS_REST_TOKEN?.trim(); + if (!url || !token) { + redis = null; + return null; + } + redis = new Redis({ url, token }); + return redis; +} + +export async function getCachedSnapshot(): Promise { + const client = getRedis(); + if (!client) return null; + try { + return (await client.get(SNAPSHOT_KEY)) ?? null; + } catch (err) { + console.warn("[upstash] get snapshot failed", err); + return null; + } +} + +export async function setCachedSnapshot(snapshot: T): Promise { + const client = getRedis(); + if (!client) return; + try { + await client.set(SNAPSHOT_KEY, snapshot, { ex: SNAPSHOT_TTL_SEC }); + } catch (err) { + console.warn("[upstash] set snapshot failed", err); + } +} diff --git a/src/infrastructure/exchanges/base.ts b/src/infrastructure/exchanges/base.ts index 1716fe4..5cc9398 100644 --- a/src/infrastructure/exchanges/base.ts +++ b/src/infrastructure/exchanges/base.ts @@ -1,5 +1,7 @@ import WebSocket from "ws"; +import * as Sentry from "@sentry/node"; import type { ExchangeId, OrderBook } from "../../domain/entities/index.js"; +import { withSpan } from "../../instrumentation/otel.js"; import { createLogger, type Logger } from "../logging/logger.js"; import type { MarketDataFeed } from "../../domain/ports/ports.js"; import { LocalBook } from "./local-book.js"; @@ -56,12 +58,16 @@ export abstract class ExchangeConnector implements MarketDataFeed { ws.on("open", () => { this.reconnectAttempts = 0; this.book.reset(); - this.log.info(this.skipSubscribe() ? "connected" : "connected, subscribing"); + this.log.info( + this.skipSubscribe() ? "connected" : "connected, subscribing", + ); if (!this.skipSubscribe()) { try { ws.send(JSON.stringify(this.subscribeMessage())); } catch (err) { - this.log.error("subscribe send failed", err); + this.log.error("subscribe send failed", { + error: err instanceof Error ? err.message : String(err), + }); } } this.startPing(); @@ -72,15 +78,27 @@ export abstract class ExchangeConnector implements MarketDataFeed { const text = data.toString(); // Some exchanges (OKX) reply to app-level pings with a plain "pong" frame. if (text === "pong" || text === "ping") return; - try { - this.handleMessage(JSON.parse(text)); - } catch (err) { - this.log.warn("failed to parse message", err); - } + void withSpan( + "ws.message", + { exchange: this.id, bytes: text.length }, + async () => { + try { + this.handleMessage(JSON.parse(text)); + } catch (err) { + this.log.warn("failed to parse message", { + error: err instanceof Error ? err.message : String(err), + }); + } + }, + ); }); ws.on("error", (err) => { - this.log.error("socket error", err instanceof Error ? err.message : err); + const error = err instanceof Error ? err : new Error(String(err)); + this.log.error("socket error", { error: error.message }); + Sentry.captureException(error, { + tags: { exchange: this.id, component: "ws" }, + }); }); ws.on("close", () => { @@ -97,8 +115,13 @@ export abstract class ExchangeConnector implements MarketDataFeed { private scheduleReconnect(): void { this.reconnectAttempts += 1; - const delay = Math.min(30_000, 500 * 2 ** Math.min(this.reconnectAttempts, 6)); - this.log.warn(`disconnected, reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`); + const delay = Math.min( + 30_000, + 500 * 2 ** Math.min(this.reconnectAttempts, 6), + ); + this.log.warn( + `disconnected, reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`, + ); setTimeout(() => { if (!this.closed) this.connect(); }, delay); diff --git a/src/infrastructure/logging/correlation.ts b/src/infrastructure/logging/correlation.ts new file mode 100644 index 0000000..76a6fc5 --- /dev/null +++ b/src/infrastructure/logging/correlation.ts @@ -0,0 +1,39 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { randomUUID } from "node:crypto"; + +export interface CorrelationContext { + correlationId: string; + exchange?: string; +} + +const storage = new AsyncLocalStorage(); + +export function getCorrelationContext(): CorrelationContext | undefined { + return storage.getStore(); +} + +export function getCorrelationId(): string | undefined { + return storage.getStore()?.correlationId; +} + +export function runWithCorrelation( + context: CorrelationContext, + fn: () => T, +): T { + return storage.run(context, fn); +} + +export function newCorrelationId(prefix = "tick"): string { + return `${prefix}_${randomUUID().slice(0, 8)}`; +} + +export function bindRequestCorrelation( + headerValue: string | string[] | undefined, +): CorrelationContext { + const raw = Array.isArray(headerValue) ? headerValue[0] : headerValue; + const correlationId = + typeof raw === "string" && raw.trim() + ? raw.trim() + : newCorrelationId("req"); + return { correlationId }; +} diff --git a/src/infrastructure/logging/logger.ts b/src/infrastructure/logging/logger.ts index f1e9938..21a37cb 100644 --- a/src/infrastructure/logging/logger.ts +++ b/src/infrastructure/logging/logger.ts @@ -1,22 +1,38 @@ -type Level = "info" | "warn" | "error"; +import pino from "pino"; +import { getCorrelationContext } from "./correlation.js"; -function emit(level: Level, scope: string, msg: string, extra?: unknown): void { - const ts = new Date().toISOString(); - const line = `${ts} [${level.toUpperCase()}] (${scope}) ${msg}`; - if (level === "error") { - console.error(line, extra ?? ""); - } else if (level === "warn") { - console.warn(line, extra ?? ""); - } else { - console.log(line, extra ?? ""); - } -} +const root = pino({ + level: process.env.LOG_LEVEL ?? "info", + base: { service: "arbpulse" }, + timestamp: pino.stdTimeFunctions.isoTime, + ...(process.env.NODE_ENV !== "production" + ? { + transport: { + target: "pino-pretty", + options: { colorize: true, singleLine: true }, + }, + } + : {}), +}); export function createLogger(scope: string) { + const child = root.child({ scope }); return { - info: (msg: string, extra?: unknown) => emit("info", scope, msg, extra), - warn: (msg: string, extra?: unknown) => emit("warn", scope, msg, extra), - error: (msg: string, extra?: unknown) => emit("error", scope, msg, extra), + info: (msg: string, extra?: Record) => + child.info({ ...bindings(), ...extra }, msg), + warn: (msg: string, extra?: Record) => + child.warn({ ...bindings(), ...extra }, msg), + error: (msg: string, extra?: Record) => + child.error({ ...bindings(), ...extra }, msg), + }; +} + +function bindings(): Record { + const ctx = getCorrelationContext(); + if (!ctx) return {}; + return { + correlationId: ctx.correlationId, + ...(ctx.exchange ? { exchange: ctx.exchange } : {}), }; } diff --git a/src/instrumentation/index.ts b/src/instrumentation/index.ts new file mode 100644 index 0000000..e44fe7b --- /dev/null +++ b/src/instrumentation/index.ts @@ -0,0 +1,7 @@ +import { initOpenTelemetry } from "./otel.js"; +import { initSentry } from "./sentry.js"; + +export function initInstrumentation(): void { + initSentry(); + initOpenTelemetry(); +} diff --git a/src/instrumentation/otel.ts b/src/instrumentation/otel.ts new file mode 100644 index 0000000..5119d36 --- /dev/null +++ b/src/instrumentation/otel.ts @@ -0,0 +1,52 @@ +import * as Sentry from "@sentry/node"; +import { trace, type Span, type Tracer } from "@opentelemetry/api"; +import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; +import { SentrySpanProcessor, SentryPropagator } from "@sentry/opentelemetry"; + +let tracer: Tracer | null = null; + +/** + * OpenTelemetry spans export to Sentry via @sentry/opentelemetry when SENTRY_DSN is set. + * Without a DSN, spans are no-ops (safe for local dev and tests). + */ +export function initOpenTelemetry(): Tracer { + if (tracer) return tracer; + + if (!process.env.SENTRY_DSN?.trim()) { + tracer = trace.getTracer("arbpulse"); + return tracer; + } + + const provider = new NodeTracerProvider({ + spanProcessors: [new SentrySpanProcessor()], + }); + provider.register({ + propagator: new SentryPropagator(), + }); + + tracer = provider.getTracer("arbpulse"); + return tracer; +} + +export function getTracer(): Tracer { + return tracer ?? initOpenTelemetry(); +} + +export async function withSpan( + name: string, + attributes: Record, + fn: (span: Span) => T | Promise, +): Promise { + const activeTracer = getTracer(); + return activeTracer.startActiveSpan(name, { attributes }, async (span) => { + try { + return await fn(span); + } catch (err) { + span.setStatus({ code: 2, message: String(err) }); + Sentry.captureException(err); + throw err; + } finally { + span.end(); + } + }); +} diff --git a/src/instrumentation/sentry.ts b/src/instrumentation/sentry.ts new file mode 100644 index 0000000..4edf1ba --- /dev/null +++ b/src/instrumentation/sentry.ts @@ -0,0 +1,26 @@ +import * as Sentry from "@sentry/node"; + +const dsn = process.env.SENTRY_DSN?.trim(); + +export function initSentry(): void { + if (!dsn) return; + + Sentry.init({ + dsn, + environment: process.env.NODE_ENV ?? "development", + tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1, + integrations: [Sentry.httpIntegration(), Sentry.expressIntegration()], + }); + + process.on("unhandledRejection", (reason) => { + Sentry.captureException( + reason instanceof Error ? reason : new Error(String(reason)), + ); + }); + + process.on("uncaughtException", (error) => { + Sentry.captureException(error); + }); +} + +export { Sentry }; diff --git a/src/interfaces/http/rate-limit.ts b/src/interfaces/http/rate-limit.ts new file mode 100644 index 0000000..ce2ae1e --- /dev/null +++ b/src/interfaces/http/rate-limit.ts @@ -0,0 +1,86 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; +import type { NextFunction, Request, Response } from "express"; +import { isUpstashConfigured } from "../cache/upstash.js"; + +type RouteTier = "read" | "stream" | "write" | "health"; + +const LIMITS: Record< + Exclude, + { requests: number; window: `${number} s` | `${number} m` } +> = { + read: { requests: 60, window: "1 m" }, + stream: { requests: 10, window: "1 m" }, + write: { requests: 30, window: "1 m" }, +}; + +const limiters = new Map, Ratelimit>(); + +function getLimiter(tier: Exclude): Ratelimit | null { + if (!isUpstashConfigured()) { + return null; + } + let limiter = limiters.get(tier); + if (limiter) { + return limiter; + } + const url = process.env.UPSTASH_REDIS_REST_URL!.trim(); + const token = process.env.UPSTASH_REDIS_REST_TOKEN!.trim(); + const redis = new Redis({ url, token }); + const cfg = LIMITS[tier]; + limiter = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(cfg.requests, cfg.window), + prefix: `arbpulse:${tier}`, + }); + limiters.set(tier, limiter); + return limiter; +} + +function clientKey(req: Request): string { + const forwarded = req.header("x-forwarded-for"); + if (forwarded) { + return forwarded.split(",")[0]?.trim() || "unknown"; + } + return req.ip || req.socket.remoteAddress || "unknown"; +} + +function tierForPath(path: string, method: string): RouteTier { + if (path === "/health") return "health"; + if (path === "/stream") return "stream"; + if (method === "GET" || method === "HEAD") return "read"; + return "write"; +} + +export function createRateLimitMiddleware() { + return async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { + const tier = tierForPath(req.path, req.method); + if (tier === "health") { + next(); + return; + } + + const limiter = getLimiter(tier); + if (!limiter) { + next(); + return; + } + + const { success, reset } = await limiter.limit(`${tier}:${clientKey(req)}`); + if (success) { + next(); + return; + } + + const retryAfterSec = Math.max(1, Math.ceil((reset - Date.now()) / 1000)); + res.setHeader("Retry-After", String(retryAfterSec)); + res.status(429).json({ + success: false, + error: "Too many requests", + }); + }; +} diff --git a/src/interfaces/http/routes.ts b/src/interfaces/http/routes.ts index 805b23a..8f035f1 100644 --- a/src/interfaces/http/routes.ts +++ b/src/interfaces/http/routes.ts @@ -1,6 +1,20 @@ -import { Router, type Request, type Response } from "express"; +import { + Router, + type NextFunction, + type Request, + type Response, +} from "express"; import type { ApplicationService } from "../../composition/application-service.js"; +import { + bindRequestCorrelation, + runWithCorrelation, +} from "../../infrastructure/logging/correlation.js"; +import { + getCachedSnapshot, + setCachedSnapshot, +} from "../../infrastructure/cache/upstash.js"; import type { SseHub } from "../sse/sse.js"; +import { createRateLimitMiddleware } from "./rate-limit.js"; import { ConfigPatchSchema, DemoControlBodySchema, @@ -13,12 +27,28 @@ import { parseBody } from "./validate.js"; export function createRouter(app: ApplicationService, sse: SseHub): Router { const router = Router(); + router.use((req: Request, res: Response, next: NextFunction) => { + const ctx = bindRequestCorrelation(req.header("x-request-id")); + res.setHeader("x-request-id", ctx.correlationId); + runWithCorrelation(ctx, () => next()); + }); + + router.use(createRateLimitMiddleware()); + router.get("/health", (_req: Request, res: Response) => { res.json({ success: true, data: { status: "ok", ts: Date.now() } }); }); - router.get("/state", (_req: Request, res: Response) => { - res.json({ success: true, data: app.getSnapshot() }); + router.get("/state", async (_req: Request, res: Response) => { + const cached = + await getCachedSnapshot>(); + if (cached) { + res.json({ success: true, data: cached }); + return; + } + const snapshot = app.getSnapshot(); + void setCachedSnapshot(snapshot); + res.json({ success: true, data: snapshot }); }); router.get("/stream", (req: Request, res: Response) => { diff --git a/src/interfaces/sse/sse.ts b/src/interfaces/sse/sse.ts index cfc8f85..92d0e07 100644 --- a/src/interfaces/sse/sse.ts +++ b/src/interfaces/sse/sse.ts @@ -1,6 +1,9 @@ import type { Request, Response } from "express"; +import * as Sentry from "@sentry/node"; import { config } from "../../infrastructure/config/config.js"; +import { setCachedSnapshot } from "../../infrastructure/cache/upstash.js"; import { createLogger } from "../../infrastructure/logging/logger.js"; +import { withSpan } from "../../instrumentation/otel.js"; import type { ApplicationService } from "../../composition/application-service.js"; const log = createLogger("sse"); @@ -22,26 +25,58 @@ export class SseHub { } handle(req: Request, res: Response): void { - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive", - "X-Accel-Buffering": "no", - }); - res.write(`retry: 2000\n\n`); - this.send(res, this.app.getSnapshot()); - this.clients.add(res); - log.info(`client connected (${this.clients.size} total)`); + try { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }); + res.write(`retry: 2000\n\n`); + this.send(res, this.app.getSnapshot()); + this.clients.add(res); + log.info(`client connected`, { clientCount: this.clients.size }); - req.on("close", () => { - this.clients.delete(res); - }); + req.on("close", () => { + this.clients.delete(res); + }); + + res.on("error", (err) => { + const error = err instanceof Error ? err : new Error(String(err)); + log.error("sse client error", { error: error.message }); + Sentry.captureException(error, { tags: { component: "sse" } }); + this.clients.delete(res); + }); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + log.error("sse handle failed", { error: error.message }); + Sentry.captureException(error, { tags: { component: "sse" } }); + if (!res.headersSent) { + res.status(500).json({ success: false, error: "SSE setup failed" }); + } + } } private broadcast(): void { if (this.clients.size === 0) return; - const snapshot = this.app.getSnapshot(); - for (const res of this.clients) this.send(res, snapshot); + void withSpan( + "sse.broadcast", + { clientCount: this.clients.size }, + async () => { + const snapshot = this.app.getSnapshot(); + void setCachedSnapshot(snapshot); + for (const res of this.clients) { + try { + this.send(res, snapshot); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + log.warn("sse send failed", { error: error.message }); + Sentry.captureException(error, { tags: { component: "sse" } }); + this.clients.delete(res); + } + } + }, + ); } private send(res: Response, data: unknown): void { diff --git a/web/package-lock.json b/web/package-lock.json index 38deb77..f2d8053 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -28,14 +28,23 @@ "version": "1.0.0", "dependencies": { "@asteasolutions/zod-to-openapi": "^8.5.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/sdk-trace-node": "^2.7.1", "@scalar/express-api-reference": "^0.8.48", + "@sentry/node": "^10.57.0", + "@sentry/opentelemetry": "^10.57.0", + "@upstash/ratelimit": "^2.0.6", + "@upstash/redis": "^1.35.3", "express": "^4.21.2", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", "tsx": "^4.19.2", "ws": "^8.18.0", "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^9.18.0", + "@playwright/test": "^1.51.1", "@types/express": "^4.17.21", "@types/node": "^22.10.5", "@types/ws": "^8.5.13", From e04f3977c64bddb1bc4dd419d73e2d5c1efc96e4 Mon Sep 17 00:00:00 2001 From: Mauricio Barragan Date: Thu, 11 Jun 2026 03:06:42 -0600 Subject: [PATCH 4/7] Fix Upstash import path blocking server start (M5 QA). rate-limit.ts imported from a non-existent interfaces/cache path; use infrastructure/cache so Playwright smoke can start the server. --- src/interfaces/http/rate-limit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interfaces/http/rate-limit.ts b/src/interfaces/http/rate-limit.ts index ce2ae1e..248c11c 100644 --- a/src/interfaces/http/rate-limit.ts +++ b/src/interfaces/http/rate-limit.ts @@ -1,7 +1,7 @@ import { Ratelimit } from "@upstash/ratelimit"; import { Redis } from "@upstash/redis"; import type { NextFunction, Request, Response } from "express"; -import { isUpstashConfigured } from "../cache/upstash.js"; +import { isUpstashConfigured } from "../../infrastructure/cache/upstash.js"; type RouteTier = "read" | "stream" | "write" | "health"; From 8c7127cfde8faaf0f1736f30811b1e017522213c Mon Sep 17 00:00:00 2001 From: Mauricio Barragan Date: Fri, 12 Jun 2026 10:38:08 -0600 Subject: [PATCH 5/7] Add Sentry dev probe and observability smoke test. GET /api/debug/sentry, load .env.local for DSN, Playwright observability spec. Co-authored-by: Cursor --- README.md | 2 +- e2e/observability.spec.ts | 8 ++++++++ package.json | 1 + src/index.ts | 1 + src/interfaces/http/routes.ts | 10 ++++++++++ src/load-env.ts | 9 +++++++++ 6 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 e2e/observability.spec.ts create mode 100644 src/load-env.ts diff --git a/README.md b/README.md index 14d70a5..0aa9980 100644 --- a/README.md +++ b/README.md @@ -347,7 +347,7 @@ Eso no indica que el bot “no funcione”: indica que el filtro económico es e - **Pre-commit:** Husky runs lint-staged (`eslint --fix`, `prettier --write`) on staged `*.ts` / `*.tsx` in `src/` and `web/src/`. - **API contracts:** Zod schemas per REST endpoint → OpenAPI via `@asteasolutions/zod-to-openapi` → Scalar UI at `/api-docs`. Request bodies validated with Zod in route handlers. -- **Observability:** `@sentry/node` captures REST, WebSocket feed, and SSE errors; `pino` JSON logs with `correlationId` (from `x-request-id` or per book tick); OpenTelemetry spans (`ws.message` → `orderbook.process` → `arbitrage.evaluate` → `sse.broadcast`) export to Sentry via `@sentry/opentelemetry` when `SENTRY_DSN` is set. +- **Observability:** `@sentry/node` captures REST, WebSocket feed, and SSE errors; `pino` JSON logs with `correlationId` (from `x-request-id` or per book tick); OpenTelemetry spans (`ws.message` → `orderbook.process` → `arbitrage.evaluate` → `sse.broadcast`) export to Sentry via `@sentry/opentelemetry` when `SENTRY_DSN` is set. Dev probe: `GET /api/debug/sentry`; verify with `npm run test:observability`. - **Rate limiting & cache:** Upstash sliding-window limits per IP on read/write/SSE routes (`429` + `Retry-After`). Latest `StateSnapshot` cached in Redis (~1s TTL) for `GET /api/state` and refreshed on SSE broadcast. Set `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN`. - **CI:** GitHub Actions quality pipeline (typecheck, unit tests, build); Playwright smoke on PRs — dashboard loads and SSE connects (`npm run test:e2e`, workflow `e2e.yml`, `DEMO_MODE=true` in CI). - **Security scanning:** CodeQL (`.github/workflows/codeql.yml`); Dependabot for npm (root + `web/`) and GitHub Actions. diff --git a/e2e/observability.spec.ts b/e2e/observability.spec.ts new file mode 100644 index 0000000..5fb3b67 --- /dev/null +++ b/e2e/observability.spec.ts @@ -0,0 +1,8 @@ +import { test, expect } from "@playwright/test"; + +/** Asserts the dev Sentry probe responds; does not verify ingest in sentry.io (see README). */ +test("sentry dev probe returns ok", async ({ request }) => { + const res = await request.get("/api/debug/sentry"); + expect(res.ok()).toBeTruthy(); + await expect(res.json()).resolves.toMatchObject({ ok: true }); +}); diff --git a/package.json b/package.json index 5cc4021..70194da 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "typecheck": "tsc --noEmit && npm --prefix web run typecheck", "test": "node scripts/run-tests.mjs", "test:e2e": "playwright test", + "test:observability": "playwright test observability.spec.ts", "lint": "eslint .", "prepare": "husky" }, diff --git a/src/index.ts b/src/index.ts index 926a3fc..978a1bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import "./load-env.js"; import { initInstrumentation } from "./instrumentation/index.js"; initInstrumentation(); diff --git a/src/interfaces/http/routes.ts b/src/interfaces/http/routes.ts index 8f035f1..ceabeb3 100644 --- a/src/interfaces/http/routes.ts +++ b/src/interfaces/http/routes.ts @@ -4,6 +4,7 @@ import { type Request, type Response, } from "express"; +import { Sentry } from "../../instrumentation/sentry.js"; import type { ApplicationService } from "../../composition/application-service.js"; import { bindRequestCorrelation, @@ -39,6 +40,15 @@ export function createRouter(app: ApplicationService, sse: SseHub): Router { res.json({ success: true, data: { status: "ok", ts: Date.now() } }); }); + router.get("/debug/sentry", (_req: Request, res: Response) => { + if (process.env.NODE_ENV === "production") { + res.status(404).json({ error: "Not found" }); + return; + } + Sentry.captureException(new Error("Arb Pulse Sentry test error")); + res.json({ ok: true, message: "Test error sent to Sentry" }); + }); + router.get("/state", async (_req: Request, res: Response) => { const cached = await getCachedSnapshot>(); diff --git a/src/load-env.ts b/src/load-env.ts new file mode 100644 index 0000000..48351c9 --- /dev/null +++ b/src/load-env.ts @@ -0,0 +1,9 @@ +import { existsSync } from "node:fs"; +import { loadEnvFile } from "node:process"; + +for (const file of [".env.local", ".env"]) { + if (existsSync(file)) { + loadEnvFile(file); + break; + } +} From f6e8381c20caa92630fa62819019519172138ba4 Mon Sep 17 00:00:00 2001 From: Mauricio Barragan Date: Wed, 17 Jun 2026 21:35:11 -0600 Subject: [PATCH 6/7] test(m3): add Upstash read-tier rate limit HTTP test Node test asserts 429 with Retry-After after 60 GET requests per IP. Co-authored-by: Cursor --- .env.example | 4 +- README.md | 2 +- package.json | 1 + src/interfaces/http/rate-limit.test.ts | 60 ++++++++++++++++++++++++++ src/test-support/load-env-local.ts | 35 +++++++++++++++ 5 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 src/interfaces/http/rate-limit.test.ts create mode 100644 src/test-support/load-env-local.ts diff --git a/.env.example b/.env.example index ac3f910..aaf6c92 100644 --- a/.env.example +++ b/.env.example @@ -31,5 +31,5 @@ SENTRY_DSN= LOG_LEVEL=info # Upstash Redis — rate limit on /api/* and short-TTL snapshot cache for GET /api/state -UPSTASH_REDIS_REST_URL= -UPSTASH_REDIS_REST_TOKEN= +UPSTASH_REDIS_REST_URL="https://natural-baboon-144595.upstash.io" +UPSTASH_REDIS_REST_TOKEN="gQAAAAAAAjTTAAIgcDE0MDk4MGZlOWMwN2M0NTdiOWQ5MWYxMjVhOWMyNWVlZQ" diff --git a/README.md b/README.md index 0aa9980..71d194f 100644 --- a/README.md +++ b/README.md @@ -348,7 +348,7 @@ Eso no indica que el bot “no funcione”: indica que el filtro económico es e - **Pre-commit:** Husky runs lint-staged (`eslint --fix`, `prettier --write`) on staged `*.ts` / `*.tsx` in `src/` and `web/src/`. - **API contracts:** Zod schemas per REST endpoint → OpenAPI via `@asteasolutions/zod-to-openapi` → Scalar UI at `/api-docs`. Request bodies validated with Zod in route handlers. - **Observability:** `@sentry/node` captures REST, WebSocket feed, and SSE errors; `pino` JSON logs with `correlationId` (from `x-request-id` or per book tick); OpenTelemetry spans (`ws.message` → `orderbook.process` → `arbitrage.evaluate` → `sse.broadcast`) export to Sentry via `@sentry/opentelemetry` when `SENTRY_DSN` is set. Dev probe: `GET /api/debug/sentry`; verify with `npm run test:observability`. -- **Rate limiting & cache:** Upstash sliding-window limits per IP on read/write/SSE routes (`429` + `Retry-After`). Latest `StateSnapshot` cached in Redis (~1s TTL) for `GET /api/state` and refreshed on SSE broadcast. Set `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN`. +- **Rate limiting & cache:** Upstash sliding-window limits per IP on read/write/SSE routes (`429` + `Retry-After`). Latest `StateSnapshot` cached in Redis (~1s TTL) for `GET /api/state` and refreshed on SSE broadcast. Set `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN`. Verify with `npm run test:rate-limit`. - **CI:** GitHub Actions quality pipeline (typecheck, unit tests, build); Playwright smoke on PRs — dashboard loads and SSE connects (`npm run test:e2e`, workflow `e2e.yml`, `DEMO_MODE=true` in CI). - **Security scanning:** CodeQL (`.github/workflows/codeql.yml`); Dependabot for npm (root + `web/`) and GitHub Actions. diff --git a/package.json b/package.json index 70194da..80bd5e0 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build": "npm --prefix web install && npm --prefix web run build", "typecheck": "tsc --noEmit && npm --prefix web run typecheck", "test": "node scripts/run-tests.mjs", + "test:rate-limit": "node --import tsx --test src/interfaces/http/rate-limit.test.ts", "test:e2e": "playwright test", "test:observability": "playwright test observability.spec.ts", "lint": "eslint .", diff --git a/src/interfaces/http/rate-limit.test.ts b/src/interfaces/http/rate-limit.test.ts new file mode 100644 index 0000000..78c9ace --- /dev/null +++ b/src/interfaces/http/rate-limit.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import type { AddressInfo } from "node:net"; +import { test } from "node:test"; +import express, { Router } from "express"; +import { isUpstashConfigured } from "../../infrastructure/cache/upstash.js"; +import { loadEnvLocal } from "../../test-support/load-env-local.js"; +import { createRateLimitMiddleware } from "./rate-limit.js"; + +loadEnvLocal(); + +const READ_LIMIT_PER_MIN = 60; + +test( + "read tier returns 429 with Retry-After after limit", + { skip: !isUpstashConfigured() }, + async () => { + const app = express(); + const router = Router(); + router.use(createRateLimitMiddleware()); + router.get("/state", (_req, res) => { + res.json({ success: true, data: { ok: true } }); + }); + app.use("/api", router); + + const server = await new Promise>( + (resolve) => { + const s = app.listen(0, "127.0.0.1", () => resolve(s)); + }, + ); + const port = (server.address() as AddressInfo).port; + const url = `http://127.0.0.1:${port}/api/state`; + const clientIp = `rate-limit-qa-${randomUUID()}`; + + try { + for (let i = 0; i < READ_LIMIT_PER_MIN; i++) { + const res = await fetch(url, { + headers: { "x-forwarded-for": clientIp }, + }); + assert.equal(res.status, 200, `request ${i + 1} should pass`); + } + + const blocked = await fetch(url, { + headers: { "x-forwarded-for": clientIp }, + }); + assert.equal(blocked.status, 429); + assert.ok(blocked.headers.get("retry-after")); + const body = (await blocked.json()) as { error?: string }; + assert.equal(body.error, "Too many requests"); + } finally { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + } + }, +); + +test("reports whether Upstash REST credentials are set", () => { + assert.equal(typeof isUpstashConfigured(), "boolean"); +}); diff --git a/src/test-support/load-env-local.ts b/src/test-support/load-env-local.ts new file mode 100644 index 0000000..6c717ec --- /dev/null +++ b/src/test-support/load-env-local.ts @@ -0,0 +1,35 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** Loads `.env.local` without overriding variables already set in the shell. */ +export function loadEnvLocal(): void { + const file = join(process.cwd(), ".env.local"); + if (!existsSync(file)) { + return; + } + + for (const line of readFileSync(file, "utf8").split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + continue; + } + + const eq = trimmed.indexOf("="); + if (eq <= 0) { + continue; + } + + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + if (process.env[key] === undefined) { + process.env[key] = value; + } + } +} From a5c83ee026bbe41349c8fbbcc854c33103184e58 Mon Sep 17 00:00:00 2001 From: Mauricio Barragan Date: Thu, 16 Jul 2026 12:54:15 -0600 Subject: [PATCH 7/7] feat(observability): gate Sentry tracing spans behind SENTRY_TRACING Spans are now created and exported to Sentry only when SENTRY_TRACING is enabled (default off), keeping 24/7 operation within the free-tier span quota. Error monitoring stays always-on. Includes OpenSpec change gate-sentry-spans (proposal, design, specs, tasks). Co-authored-by: Cursor --- .env.example | 4 ++ README.md | 2 +- .../changes/gate-sentry-spans/.openspec.yaml | 2 + openspec/changes/gate-sentry-spans/design.md | 34 +++++++++++++++ .../changes/gate-sentry-spans/proposal.md | 25 +++++++++++ .../specs/observability/spec.md | 43 +++++++++++++++++++ openspec/changes/gate-sentry-spans/tasks.md | 16 +++++++ openspec/config.yaml | 20 +++++++++ src/instrumentation/otel.ts | 9 ++-- src/instrumentation/sentry.ts | 8 +++- src/instrumentation/tracing.ts | 11 +++++ 11 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 openspec/changes/gate-sentry-spans/.openspec.yaml create mode 100644 openspec/changes/gate-sentry-spans/design.md create mode 100644 openspec/changes/gate-sentry-spans/proposal.md create mode 100644 openspec/changes/gate-sentry-spans/specs/observability/spec.md create mode 100644 openspec/changes/gate-sentry-spans/tasks.md create mode 100644 openspec/config.yaml create mode 100644 src/instrumentation/tracing.ts diff --git a/.env.example b/.env.example index aaf6c92..7763656 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,10 @@ RECORD_FEED=false # Observability — https://sentry.io (project: arbpulse) SENTRY_DSN= +# Tracing/spans gate — default off. Error monitoring is always on when SENTRY_DSN +# is set; spans are only created/exported when SENTRY_TRACING is enabled. Kept off +# by default so 24/7 operation stays within Sentry's free-tier span quota. +SENTRY_TRACING=false # Structured logs (pino); correlation IDs on REST, SSE, and WS hot path LOG_LEVEL=info diff --git a/README.md b/README.md index 71d194f..3bc5a3d 100644 --- a/README.md +++ b/README.md @@ -347,7 +347,7 @@ Eso no indica que el bot “no funcione”: indica que el filtro económico es e - **Pre-commit:** Husky runs lint-staged (`eslint --fix`, `prettier --write`) on staged `*.ts` / `*.tsx` in `src/` and `web/src/`. - **API contracts:** Zod schemas per REST endpoint → OpenAPI via `@asteasolutions/zod-to-openapi` → Scalar UI at `/api-docs`. Request bodies validated with Zod in route handlers. -- **Observability:** `@sentry/node` captures REST, WebSocket feed, and SSE errors; `pino` JSON logs with `correlationId` (from `x-request-id` or per book tick); OpenTelemetry spans (`ws.message` → `orderbook.process` → `arbitrage.evaluate` → `sse.broadcast`) export to Sentry via `@sentry/opentelemetry` when `SENTRY_DSN` is set. Dev probe: `GET /api/debug/sentry`; verify with `npm run test:observability`. +- **Observability:** `@sentry/node` captures REST, WebSocket feed, and SSE errors (always on when `SENTRY_DSN` is set); `pino` JSON logs with `correlationId` (from `x-request-id` or per book tick); OpenTelemetry spans (`ws.message` → `orderbook.process` → `arbitrage.evaluate` → `sse.broadcast`) export to Sentry via `@sentry/opentelemetry` **only when `SENTRY_TRACING` is enabled** (default off, so 24/7 operation stays within the free-tier span quota). With tracing off the tracer is a no-op — no spans are created or exported — but errors are still captured. Dev probe: `GET /api/debug/sentry`; verify with `npm run test:observability`. - **Rate limiting & cache:** Upstash sliding-window limits per IP on read/write/SSE routes (`429` + `Retry-After`). Latest `StateSnapshot` cached in Redis (~1s TTL) for `GET /api/state` and refreshed on SSE broadcast. Set `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN`. Verify with `npm run test:rate-limit`. - **CI:** GitHub Actions quality pipeline (typecheck, unit tests, build); Playwright smoke on PRs — dashboard loads and SSE connects (`npm run test:e2e`, workflow `e2e.yml`, `DEMO_MODE=true` in CI). - **Security scanning:** CodeQL (`.github/workflows/codeql.yml`); Dependabot for npm (root + `web/`) and GitHub Actions. diff --git a/openspec/changes/gate-sentry-spans/.openspec.yaml b/openspec/changes/gate-sentry-spans/.openspec.yaml new file mode 100644 index 0000000..cd2ce7e --- /dev/null +++ b/openspec/changes/gate-sentry-spans/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-16 diff --git a/openspec/changes/gate-sentry-spans/design.md b/openspec/changes/gate-sentry-spans/design.md new file mode 100644 index 0000000..25b4b0e --- /dev/null +++ b/openspec/changes/gate-sentry-spans/design.md @@ -0,0 +1,34 @@ +## Context + +`src/instrumentation/otel.ts` builds a tracer that, when `SENTRY_DSN` is set, registers a `NodeTracerProvider` with `SentrySpanProcessor`/`SentryPropagator`, so every `withSpan(...)` call on the hot path creates and exports a span to Sentry. `src/instrumentation/sentry.ts` sets `tracesSampleRate` unconditionally. Running 24/7 on a VPS multiplies span volume against Sentry's free-tier quota (5M spans/month), while error events stay small. We want tracing to remain a debugging option but be off by default. + +## Goals / Non-Goals + +**Goals:** +- Add a single boolean env var (`SENTRY_TRACING`, default `false`) that turns span creation/export on or off. +- Default behavior emits zero spans and zero hot-path span allocation. +- Preserve error monitoring exactly as-is regardless of the flag. +- No dependency changes; no API/wire changes. + +**Non-Goals:** +- Removing OpenTelemetry or the `withSpan` abstraction. +- Making `tracesSampleRate` itself env-configurable (kept as current 0.1 prod / 1 dev when enabled). +- Changing which operations are wrapped. + +## Decisions + +- **Boolean gate over sample-rate gate.** A boolean `SENTRY_TRACING` that makes the tracer a no-op avoids hot-path span allocation entirely when off (aligns with the "no unnecessary alloc on the hot path" convention). A `tracesSampleRate=0` approach would still register the provider and create spans that are dropped later — more overhead. Alternative considered and rejected. +- **Gate lives in `otel.ts` init.** `initOpenTelemetry()` returns the no-op `trace.getTracer("arbpulse")` unless both `SENTRY_DSN` and `SENTRY_TRACING` are truthy. This keeps `withSpan`'s signature and all call sites unchanged; its `catch` still calls `Sentry.captureException`, so errors are captured even with a no-op span. +- **Conditional `tracesSampleRate` in `sentry.ts`.** Only set `tracesSampleRate` when tracing is enabled; omit it otherwise so Sentry performance is fully off. A shared helper `isTracingEnabled()` reads the flag so both files agree. +- **Parsing.** Reuse the existing truthy convention (`"1"` or `"true"`, case-insensitive) used by `config.ts`'s `bool()` for consistency; implement locally in the instrumentation module since it initializes before `config` import. + +## Risks / Trade-offs + +- [Someone enables tracing in prod and hits the quota] → Documented in `.env.example`/README that it is off by default and why; enabling is an explicit opt-in. +- [Flag read in two files could drift] → Centralize in one `isTracingEnabled()` helper exported from the instrumentation layer. +- [No-op tracer still wraps hot path in a promise] → Accepted: `withSpan` remains async as today; the change only removes span export, matching prior behavior when `SENTRY_DSN` was unset. + +## Migration Plan + +- Deploy with `SENTRY_TRACING` unset → tracing off (safe default), errors still reported. +- To debug, set `SENTRY_TRACING=true` (with `SENTRY_DSN`) temporarily; unset to roll back. No data migration. diff --git a/openspec/changes/gate-sentry-spans/proposal.md b/openspec/changes/gate-sentry-spans/proposal.md new file mode 100644 index 0000000..07e7aed --- /dev/null +++ b/openspec/changes/gate-sentry-spans/proposal.md @@ -0,0 +1,25 @@ +## Why + +Arb Pulse is moving to a 24/7 VPS (Hetzner), where the engine runs continuously and emits OpenTelemetry spans on the hot path (`ws.message` → `orderbook.process` → `arbitrage.evaluate` → `sse.broadcast`). At that volume the app can approach or exceed Sentry's free-tier span quota (5M spans/month), while error monitoring stays well within limits. We want to keep tracing available for debugging but off by default so continuous operation is free-tier safe. + +## What Changes + +- Introduce an environment variable (`SENTRY_TRACING`, boolean, default `false`) that gates OpenTelemetry span creation and export to Sentry. +- When `SENTRY_TRACING` is disabled (default), the tracer is a no-op: no spans are created or exported, and no hot-path span allocation occurs. Error capture (`Sentry.captureException`, `unhandledRejection`, `uncaughtException`) remains fully active. +- When `SENTRY_TRACING` is enabled **and** `SENTRY_DSN` is set, spans are created and exported to Sentry as today, and `tracesSampleRate` is applied. +- Document the new variable in `.env.example` and `README.md`. + +## Capabilities + +### New Capabilities +- `observability`: error monitoring and optional distributed tracing behavior — how Sentry error capture is always on, and how OpenTelemetry span emission is conditioned on an environment flag. + +### Modified Capabilities + + +## Impact + +- Code: `src/instrumentation/otel.ts` (gate span provider/export), `src/instrumentation/sentry.ts` (conditional `tracesSampleRate`), possibly `src/instrumentation/index.ts`. +- Config/docs: `.env.example`, `README.md` (observability section + env var table). +- Dependencies: none added or removed (OTel packages stay for the enabled path). +- Behavior: default runtime emits zero spans; error monitoring unchanged. No API or wire-format changes. diff --git a/openspec/changes/gate-sentry-spans/specs/observability/spec.md b/openspec/changes/gate-sentry-spans/specs/observability/spec.md new file mode 100644 index 0000000..db1245f --- /dev/null +++ b/openspec/changes/gate-sentry-spans/specs/observability/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: Error monitoring is always active + +The system SHALL report runtime errors to Sentry whenever `SENTRY_DSN` is configured, independent of any tracing configuration. This includes REST, WebSocket feed, and SSE errors captured via `Sentry.captureException`, plus process-level `unhandledRejection` and `uncaughtException` handlers. + +#### Scenario: Error captured with DSN set + +- **WHEN** `SENTRY_DSN` is set and a handled error occurs on the WebSocket, REST, or SSE path +- **THEN** the error is sent to Sentry as an error event + +#### Scenario: No DSN configured + +- **WHEN** `SENTRY_DSN` is empty or unset +- **THEN** Sentry is not initialized and no events are sent, and the application continues running normally + +### Requirement: Tracing spans are gated by an environment flag + +The system SHALL only create and export OpenTelemetry spans when the `SENTRY_TRACING` environment variable is enabled AND `SENTRY_DSN` is set. The flag SHALL default to disabled so that continuous 24/7 operation emits zero spans by default. + +#### Scenario: Tracing disabled by default + +- **WHEN** `SENTRY_TRACING` is unset or falsy (default) +- **THEN** the tracer is a no-op, no spans are created on the hot path, and no spans are exported to Sentry + +#### Scenario: Tracing enabled with DSN + +- **WHEN** `SENTRY_TRACING` is truthy and `SENTRY_DSN` is set +- **THEN** hot-path spans (`ws.message`, `orderbook.process`, `arbitrage.evaluate`, `sse.broadcast`) are created and exported to Sentry, and `tracesSampleRate` is applied + +#### Scenario: Tracing enabled without DSN + +- **WHEN** `SENTRY_TRACING` is truthy but `SENTRY_DSN` is empty or unset +- **THEN** the tracer is a no-op and no spans are exported, since export requires a DSN + +### Requirement: Error capture is preserved when tracing is disabled + +The system SHALL continue to report exceptions thrown inside hot-path work wrappers to Sentry even when tracing is disabled, so disabling spans never reduces error visibility. + +#### Scenario: Exception in hot-path wrapper with tracing off + +- **WHEN** `SENTRY_TRACING` is disabled and code wrapped by the hot-path span helper throws +- **THEN** the exception is still captured and reported to Sentry (as an error), and re-thrown to preserve existing control flow diff --git a/openspec/changes/gate-sentry-spans/tasks.md b/openspec/changes/gate-sentry-spans/tasks.md new file mode 100644 index 0000000..d130556 --- /dev/null +++ b/openspec/changes/gate-sentry-spans/tasks.md @@ -0,0 +1,16 @@ +## 1. Tracing gate + +- [x] 1.1 Add `isTracingEnabled()` helper (reads `SENTRY_TRACING`, truthy = `"1"`/`"true"` case-insensitive) exported from the instrumentation layer +- [x] 1.2 In `src/instrumentation/otel.ts`, return the no-op tracer unless both `SENTRY_DSN` and `SENTRY_TRACING` are truthy (keep `withSpan` signature and its `Sentry.captureException` in catch) +- [x] 1.3 In `src/instrumentation/sentry.ts`, only set `tracesSampleRate` when tracing is enabled (omit it otherwise) + +## 2. Documentation + +- [x] 2.1 Add `SENTRY_TRACING` (default `false`) to `.env.example` with a note it gates spans for free-tier safety +- [x] 2.2 Update the README observability section to describe the flag and default-off behavior + +## 3. Verification + +- [x] 3.1 `npm run typecheck` passes +- [x] 3.2 `npm test` passes +- [x] 3.3 Manually confirm: with `SENTRY_TRACING` unset, no spans are emitted; error capture path unaffected diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..392946c --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/src/instrumentation/otel.ts b/src/instrumentation/otel.ts index 5119d36..85affac 100644 --- a/src/instrumentation/otel.ts +++ b/src/instrumentation/otel.ts @@ -2,17 +2,20 @@ import * as Sentry from "@sentry/node"; import { trace, type Span, type Tracer } from "@opentelemetry/api"; import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; import { SentrySpanProcessor, SentryPropagator } from "@sentry/opentelemetry"; +import { isTracingEnabled } from "./tracing.js"; let tracer: Tracer | null = null; /** - * OpenTelemetry spans export to Sentry via @sentry/opentelemetry when SENTRY_DSN is set. - * Without a DSN, spans are no-ops (safe for local dev and tests). + * OpenTelemetry spans export to Sentry via @sentry/opentelemetry only when + * SENTRY_DSN is set AND SENTRY_TRACING is enabled. Otherwise spans are no-ops + * (default; safe for 24/7 operation, local dev, and tests). Error capture in + * `withSpan` is preserved regardless. */ export function initOpenTelemetry(): Tracer { if (tracer) return tracer; - if (!process.env.SENTRY_DSN?.trim()) { + if (!process.env.SENTRY_DSN?.trim() || !isTracingEnabled()) { tracer = trace.getTracer("arbpulse"); return tracer; } diff --git a/src/instrumentation/sentry.ts b/src/instrumentation/sentry.ts index 4edf1ba..30a430b 100644 --- a/src/instrumentation/sentry.ts +++ b/src/instrumentation/sentry.ts @@ -1,4 +1,5 @@ import * as Sentry from "@sentry/node"; +import { isTracingEnabled } from "./tracing.js"; const dsn = process.env.SENTRY_DSN?.trim(); @@ -8,8 +9,13 @@ export function initSentry(): void { Sentry.init({ dsn, environment: process.env.NODE_ENV ?? "development", - tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1, integrations: [Sentry.httpIntegration(), Sentry.expressIntegration()], + // Tracing/spans are gated by SENTRY_TRACING (default off) to stay within + // the Sentry free-tier span quota during 24/7 operation. Error monitoring + // is always on. + ...(isTracingEnabled() + ? { tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1 } + : {}), }); process.on("unhandledRejection", (reason) => { diff --git a/src/instrumentation/tracing.ts b/src/instrumentation/tracing.ts new file mode 100644 index 0000000..e4eb5c8 --- /dev/null +++ b/src/instrumentation/tracing.ts @@ -0,0 +1,11 @@ +/** + * Single source of truth for the tracing gate. OpenTelemetry spans are only + * created and exported to Sentry when `SENTRY_TRACING` is truthy (and a + * `SENTRY_DSN` is set). Defaults to disabled so 24/7 operation stays off the + * Sentry free-tier span quota. Error monitoring is unaffected by this flag. + */ +export function isTracingEnabled(): boolean { + const raw = process.env.SENTRY_TRACING; + if (raw === undefined) return false; + return raw === "1" || raw.toLowerCase() === "true"; +}