diff --git a/backend/event_listener_git.txt b/backend/event_listener_git.txt new file mode 100644 index 0000000..2b0cba4 --- /dev/null +++ b/backend/event_listener_git.txt @@ -0,0 +1,6 @@ +commit 4eb36e6514ab8f657810abe3d4b533df754c525d +Merge: 1a36353 a18ffc6 +Author: calebux +Date: Sun Mar 29 17:42:18 2026 +0100 + + Merge PR #260: fix/137-event-listener-health-retry diff --git a/backend/git_log.txt b/backend/git_log.txt new file mode 100644 index 0000000..c09fe84 --- /dev/null +++ b/backend/git_log.txt @@ -0,0 +1,5 @@ +f74f069 Merge PR #261: feature/api-keys-endpoints +4eb36e6 Merge PR #260: fix/137-event-listener-health-retry +1a36353 Merge PR #257: compliance +fcf718f Merge PR #250: feature/rbac-role-enforcement +0427602 Merge PR #249: security/admin-api-key-validation diff --git a/backend/package-lock.json b/backend/package-lock.json index 70d70d1..e29eb41 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,32 +9,45 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@sentry/node": "^10.46.0", + "@sentry/profiling-node": "^10.46.0", + "@stellar/stellar-sdk": "^14.5.0", "@supabase/supabase-js": "^2.47.10", "@types/cookie-parser": "^1.4.10", + "@types/multer": "^2.1.0", "@types/uuid": "^10.0.0", + "archiver": "^7.0.1", "bcryptjs": "^2.4.3", + "bip39": "^3.1.0", "cookie-parser": "^1.4.7", + "csv-parse": "^6.2.1", "dotenv": "^16.4.5", "express": "^5.2.1", "express-rate-limit": "^8.3.1", + "google-auth-library": "^10.6.2", + "googleapis": "^171.4.0", + "ical-generator": "^9.0.0", "jsonwebtoken": "^8.5.1", + "multer": "^2.1.1", "node-cron": "^3.0.3", "nodemailer": "^6.9.9", + "p-limit": "^3.1.0", "rate-limit-redis": "^4.3.1", "redis": "^5.11.0", + "swagger-jsdoc": "^6.2.8", "uuid": "^13.0.0", "web-push": "^3.6.7", - "winston": "^3.14.0", "zod": "^3.23.8" }, "devDependencies": { "@stellar/stellar-sdk": "^14.5.0", + "@types/archiver": "^7.0.0", "@types/bcryptjs": "^2.4.6", - "@types/express": "^5.0.0", + "@types/express": "^5.0.6", "@types/express-rate-limit": "^5.1.3", "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.10", - "@types/node": "^20.14.0", + "@types/node": "^20.19.37", "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.14", "@types/supertest": "^6.0.3", @@ -46,7 +59,69 @@ "supertest": "^7.2.2", "ts-jest": "^29.4.6", "ts-node-dev": "^2.0.0", - "typescript": "^5.5.0" + "typescript": "^5.9.3" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser/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==", + "license": "Python-2.0" + }, + "node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" } }, "node_modules/@babel/code-frame": { @@ -80,7 +155,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -566,15 +640,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@colors/colors": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", - "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -599,17 +664,6 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@dabh/diagnostics": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", - "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", - "license": "MIT", - "dependencies": { - "@so-ric/colorspace": "^1.1.6", - "enabled": "2.0.x", - "kuler": "^2.0.0" - } - }, "node_modules/@emnapi/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", @@ -768,6 +822,72 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fastify/otel": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@fastify/otel/-/otel-0.17.1.tgz", + "integrity": "sha512-K4wyxfUZx2ux5o+b6BtTqouYFVILohLZmSbA2tKUueJstNcBnoGPVhllCaOvbQ3ZrXdUxUC/fyrSWSCqHhdOPg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.212.0", + "@opentelemetry/semantic-conventions": "^1.28.0", + "minimatch": "^10.2.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + } + }, + "node_modules/@fastify/otel/node_modules/@opentelemetry/api-logs": { + "version": "0.212.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.212.0.tgz", + "integrity": "sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@fastify/otel/node_modules/@opentelemetry/instrumentation": { + "version": "0.212.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.212.0.tgz", + "integrity": "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.212.0", + "import-in-the-middle": "^2.0.6", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@fastify/otel/node_modules/import-in-the-middle": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", + "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", + "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" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -841,7 +961,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -1328,6 +1447,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1361,7 +1486,6 @@ "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, "license": "MIT", "engines": { "node": "^14.21.3 || >=16" @@ -1408,198 +1532,903 @@ "node": ">= 8" } }, - "node_modules/@paralleldrive/cuid2": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", - "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/hashes": "^1.1.5" + "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/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, + "node_modules/@opentelemetry/api-logs": { + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.213.0.tgz", + "integrity": "sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, "engines": { - "node": ">=14" + "node": ">=8.0.0" } }, - "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.1.tgz", + "integrity": "sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ==", + "license": "Apache-2.0", "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "url": "https://opencollective.com/pkgr" + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@redis/bloom": { - "version": "5.11.0", - "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.11.0.tgz", - "integrity": "sha512-KYiVilAhAFN3057afUb/tfYJpsEyTkQB+tQcn5gVVA7DgcNOAj8lLxe4j8ov8BF6I9C1Fe/kwlbuAICcTMX8Lw==", - "license": "MIT", + "node_modules/@opentelemetry/core": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", + "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, "engines": { - "node": ">= 18" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@redis/client": "^5.11.0" + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@redis/client": { - "version": "5.11.0", - "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.11.0.tgz", - "integrity": "sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==", - "license": "MIT", - "peer": true, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.213.0.tgz", + "integrity": "sha512-3i9NdkET/KvQomeh7UaR/F4r9P25Rx6ooALlWXPIjypcEOUxksCmVu0zA70NBJWlrMW1rPr/LRidFAflLI+s/w==", + "license": "Apache-2.0", "dependencies": { - "cluster-key-slot": "1.1.2" + "@opentelemetry/api-logs": "0.213.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" }, "engines": { - "node": ">= 18" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@node-rs/xxhash": "^1.1.0" - }, - "peerDependenciesMeta": { - "@node-rs/xxhash": { - "optional": true - } + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@redis/json": { - "version": "5.11.0", - "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.11.0.tgz", - "integrity": "sha512-1iAy9kAtcD0quB21RbPTbUqqy+T2Uu2JxucwE+B4A+VaDbIRvpZR6DMqV8Iqaws2YxJYB3GC5JVNzPYio2ErUg==", - "license": "MIT", + "node_modules/@opentelemetry/instrumentation-amqplib": { + "version": "0.60.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.60.0.tgz", + "integrity": "sha512-q/B2IvoVXRm1M00MvhnzpMN6rKYOszPXVsALi6u0ss4AYHe+TidZEtLW9N1ZhrobI1dSriHnBqqtAOZVAv07sg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, "engines": { - "node": ">= 18" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@redis/client": "^5.11.0" + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@redis/search": { - "version": "5.11.0", - "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.11.0.tgz", - "integrity": "sha512-g1l7f3Rnyk/xI99oGHIgWHSKFl45Re5YTIcO8j/JE8olz389yUFyz2+A6nqVy/Zi031VgPDWscbbgOk8hlhZ3g==", - "license": "MIT", + "node_modules/@opentelemetry/instrumentation-connect": { + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.56.0.tgz", + "integrity": "sha512-PKp+sSZ7AfzMvGgO3VCyo1inwNu+q7A1k9X88WK4PQ+S6Hp7eFk8pie+sWHDTaARovmqq5V2osav3lQej2B0nw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/connect": "3.4.38" + }, "engines": { - "node": ">= 18" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@redis/client": "^5.11.0" + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@redis/time-series": { - "version": "5.11.0", - "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.11.0.tgz", - "integrity": "sha512-TWFeOcU4xkj0DkndnOyhtxvX1KWD+78UHT3XX3x3XRBUGWeQrKo3jqzDsZwxbggUgf9yLJr/akFHXru66X5UQA==", - "license": "MIT", + "node_modules/@opentelemetry/instrumentation-dataloader": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.30.0.tgz", + "integrity": "sha512-MXHP2Q38cd2OhzEBKAIXUi9uBlPEYzF6BNJbyjUXBQ6kLaf93kRC41vNMIz0Nl5mnuwK7fDvKT+/lpx7BXRwdg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.213.0" + }, "engines": { - "node": ">= 18" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@redis/client": "^5.11.0" + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@sinclair/typebox": { - "version": "0.34.48", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", - "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/@opentelemetry/instrumentation-express": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.61.0.tgz", + "integrity": "sha512-Xdmqo9RZuZlL29Flg8QdwrrX7eW1CZ7wFQPKHyXljNymgKhN1MCsYuqQ/7uxavhSKwAl7WxkTzKhnqpUApLMvQ==", + "license": "Apache-2.0", "dependencies": { - "type-detect": "4.0.8" + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@sinonjs/fake-timers": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.1.tgz", - "integrity": "sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/@opentelemetry/instrumentation-fs": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.32.0.tgz", + "integrity": "sha512-koR6apx0g0wX6RRiPpjA4AFQUQUbXrK16kq4/SZjVp7u5cffJhNkY4TnITxcGA4acGSPYAfx3NHRIv4Khn1axQ==", + "license": "Apache-2.0", "dependencies": { - "@sinonjs/commons": "^3.0.1" + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.213.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@so-ric/colorspace": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", - "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", - "license": "MIT", + "node_modules/@opentelemetry/instrumentation-generic-pool": { + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.56.0.tgz", + "integrity": "sha512-fg+Jffs6fqrf0uQS0hom7qBFKsbtpBiBl8+Vkc63Gx8xh6pVh+FhagmiO6oM0m3vyb683t1lP7yGYq22SiDnqg==", + "license": "Apache-2.0", "dependencies": { - "color": "^5.0.2", - "text-hex": "1.0.x" + "@opentelemetry/instrumentation": "^0.213.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@stellar/js-xdr": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", - "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@stellar/stellar-base": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-14.1.0.tgz", - "integrity": "sha512-A8kFli6QGy22SRF45IjgPAJfUNGjnI+R7g4DF5NZYVsD1kGf7B4ITyc4OPclLV9tqNI4/lXxafGEw0JEUbHixw==", - "dev": true, + "node_modules/@opentelemetry/instrumentation-graphql": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.61.0.tgz", + "integrity": "sha512-pUiVASv6nh2XrerTvlbVHh7vKFzscpgwiQ/xvnZuAIzQ5lRjWVdRPUuXbvZJ/Yq79QsE81TZdJ7z9YsXiss1ew==", "license": "Apache-2.0", "dependencies": { - "@noble/curves": "^1.9.6", - "@stellar/js-xdr": "^3.1.2", - "base32.js": "^0.1.0", - "bignumber.js": "^9.3.1", - "buffer": "^6.0.3", - "sha.js": "^2.4.12" + "@opentelemetry/instrumentation": "^0.213.0" }, "engines": { - "node": ">=20.0.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@stellar/stellar-sdk": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-14.6.1.tgz", - "integrity": "sha512-A1rQWDLdUasXkMXnYSuhgep+3ZZzyuXJKdt5/KAIc0gkmSp906HTvUpbT4pu+bVr41tu0+J4Ugz9J4BQAGGytg==", - "dev": true, + "node_modules/@opentelemetry/instrumentation-hapi": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.59.0.tgz", + "integrity": "sha512-33wa4mEr+9+ztwdgLor1SeBu4Opz4IsmpcLETXAd3VmBrOjez8uQtrsOhPCa5Vhbm5gzDlMYTgFRLQzf8/YHFA==", "license": "Apache-2.0", "dependencies": { - "@stellar/stellar-base": "^14.1.0", - "axios": "^1.13.3", - "bignumber.js": "^9.3.1", - "commander": "^14.0.2", - "eventsource": "^2.0.2", - "feaxios": "^0.0.23", - "randombytes": "^2.1.0", - "toml": "^3.0.0", - "urijs": "^1.19.1" - }, - "bin": { - "stellar-js": "bin/stellar-js" + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { - "node": ">=20.0.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@supabase/auth-js": { - "version": "2.100.1", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.100.1.tgz", + "node_modules/@opentelemetry/instrumentation-http": { + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.213.0.tgz", + "integrity": "sha512-B978Xsm5XEPGhm1P07grDoaOFLHapJPkOG9h016cJsyWWxmiLnPu2M/4Nrm7UCkHSiLnkXgC+zVGUAIahy8EEA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/instrumentation": "0.213.0", + "@opentelemetry/semantic-conventions": "^1.29.0", + "forwarded-parse": "2.1.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/core": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", + "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", + "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-ioredis": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.61.0.tgz", + "integrity": "sha512-hsHDadUtAFbws1YSDc1XW0svGFKiUbqv2td1Cby+UAiwvojm1NyBo/taifH0t8CuFZ0x/2SDm0iuTwrM5pnVOg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/redis-common": "^0.38.2", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-kafkajs": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.22.0.tgz", + "integrity": "sha512-wJU4IBQMUikdJAcTChLFqK5lo+flo7pahqd8DSLv7uMxsdOdAHj6RzKYAm8pPfUS6ItKYutYyuicwKaFwQKsoA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/semantic-conventions": "^1.30.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-knex": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.57.0.tgz", + "integrity": "sha512-vMCSh8kolEm5rRsc+FZeTZymWmIJwc40hjIKnXH4O0Dv/gAkJJIRXCsPX5cPbe0c0j/34+PsENd0HqKruwhVYw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/semantic-conventions": "^1.33.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-koa": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.61.0.tgz", + "integrity": "sha512-lvrfWe9ShK/D2X4brmx8ZqqeWPfRl8xekU0FCn7C1dHm5k6+rTOOi36+4fnaHAP8lig9Ux6XQ1D4RNIpPCt1WQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/semantic-conventions": "^1.36.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + } + }, + "node_modules/@opentelemetry/instrumentation-lru-memoizer": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.57.0.tgz", + "integrity": "sha512-cEqpUocSKJfwDtLYTTJehRLWzkZ2eoePCxfVIgGkGkb83fMB71O+y4MvRHJPbeV2bdoWdOVrl8uO0+EynWhTEA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.213.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongodb": { + "version": "0.66.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.66.0.tgz", + "integrity": "sha512-d7m9QnAY+4TCWI4q1QRkfrc6fo/92VwssaB1DzQfXNRvu51b78P+HJlWP7Qg6N6nkwdb9faMZNBCZJfftmszkw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongoose": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.59.0.tgz", + "integrity": "sha512-6/jWU+c1NgznkVLDU/2y0bXV2nJo3o9FWZ9mZ9nN6T/JBNRoMnVXZl2FdBmgH+a5MwaWLs5kmRJTP5oUVGIkPw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.59.0.tgz", + "integrity": "sha512-r+V/Fh0sm7Ga8/zk/TI5H5FQRAjwr0RrpfPf8kNIehlsKf12XnvIaZi8ViZkpX0gyPEpLXqzqWD6QHlgObgzZw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@types/mysql": "2.15.27" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql2": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.59.0.tgz", + "integrity": "sha512-n9/xrVCRBfG9egVbffnlU1uhr+HX0vF4GgtAB/Bvm48wpFgRidqD8msBMiym1kRYzmpWvJqTxNT47u1MkgBEdw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@opentelemetry/sql-common": "^0.41.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pg": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.65.0.tgz", + "integrity": "sha512-W0zpHEIEuyZ8zvb3njaX9AAbHgPYOsSWVOoWmv1sjVRSF6ZpBqtlxBWbU+6hhq1TFWBeWJOXZ8nZS/PUFpLJYQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/semantic-conventions": "^1.34.0", + "@opentelemetry/sql-common": "^0.41.2", + "@types/pg": "8.15.6", + "@types/pg-pool": "2.0.7" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-redis": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.61.0.tgz", + "integrity": "sha512-JnPexA034/0UJRsvH96B0erQoNOqKJZjE2ZRSw9hiTSC23LzE0nJE/u6D+xqOhgUhRnhhcPHq4MdYtmUdYTF+Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/redis-common": "^0.38.2", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-tedious": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.32.0.tgz", + "integrity": "sha512-BQS6gG8RJ1foEqfEZ+wxoqlwfCAzb1ZVG0ad8Gfe4x8T658HJCLGLd4E4NaoQd8EvPfLqOXgzGaE/2U4ytDSWA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@types/tedious": "^4.0.14" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-undici": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.23.0.tgz", + "integrity": "sha512-LL0VySzKVR2cJSFVZaTYpZl1XTpBGnfzoQPe2W7McS2267ldsaEIqtQY6VXs2KCXN0poFjze5110PIpxHDaDGg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/semantic-conventions": "^1.24.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.7.0" + } + }, + "node_modules/@opentelemetry/redis-common": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz", + "integrity": "sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", + "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.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.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", + "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.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/semantic-conventions": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", + "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sql-common": { + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.41.2.tgz", + "integrity": "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@prisma/instrumentation": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-7.4.2.tgz", + "integrity": "sha512-r9JfchJF1Ae6yAxcaLu/V1TGqBhAuSDe3mRNOssBfx1rMzfZ4fdNvrgUBwyb/TNTGXFxlH9AZix5P257x07nrg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.207.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.8" + } + }, + "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/api-logs": { + "version": "0.207.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", + "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/instrumentation": { + "version": "0.207.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.207.0.tgz", + "integrity": "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.207.0", + "import-in-the-middle": "^2.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/@prisma/instrumentation/node_modules/import-in-the-middle": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", + "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", + "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" + } + }, + "node_modules/@redis/bloom": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.11.0.tgz", + "integrity": "sha512-KYiVilAhAFN3057afUb/tfYJpsEyTkQB+tQcn5gVVA7DgcNOAj8lLxe4j8ov8BF6I9C1Fe/kwlbuAICcTMX8Lw==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.11.0" + } + }, + "node_modules/@redis/client": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.11.0.tgz", + "integrity": "sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.11.0.tgz", + "integrity": "sha512-1iAy9kAtcD0quB21RbPTbUqqy+T2Uu2JxucwE+B4A+VaDbIRvpZR6DMqV8Iqaws2YxJYB3GC5JVNzPYio2ErUg==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.11.0" + } + }, + "node_modules/@redis/search": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.11.0.tgz", + "integrity": "sha512-g1l7f3Rnyk/xI99oGHIgWHSKFl45Re5YTIcO8j/JE8olz389yUFyz2+A6nqVy/Zi031VgPDWscbbgOk8hlhZ3g==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.11.0" + } + }, + "node_modules/@redis/time-series": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.11.0.tgz", + "integrity": "sha512-TWFeOcU4xkj0DkndnOyhtxvX1KWD+78UHT3XX3x3XRBUGWeQrKo3jqzDsZwxbggUgf9yLJr/akFHXru66X5UQA==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.11.0" + } + }, + "node_modules/@sentry-internal/node-cpu-profiler": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/node-cpu-profiler/-/node-cpu-profiler-2.2.0.tgz", + "integrity": "sha512-oLHVYurqZfADPh5hvmQYS5qx8t0UZzT2u6+/68VXsFruQEOnYJTODKgU3BVLmemRs3WE6kCJjPeFdHVYOQGSzQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "node-abi": "^3.73.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/core": { + "version": "10.46.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.46.0.tgz", + "integrity": "sha512-N3fj4zqBQOhXliS1Ne9euqIKuciHCGOJfPGQLwBoW9DNz03jF+NB8+dUKtrJ79YLoftjVgf8nbgwtADK7NR+2Q==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node": { + "version": "10.46.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.46.0.tgz", + "integrity": "sha512-vF+7FrUXEtmYWuVcnvBjlWKeyLw/kwHpwnGj9oUmO/a2uKjDmUr53ZVcapggNxCjivavGYr9uHOY64AGdeUyzA==", + "license": "MIT", + "dependencies": { + "@fastify/otel": "0.17.1", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^2.6.0", + "@opentelemetry/core": "^2.6.0", + "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/instrumentation-amqplib": "0.60.0", + "@opentelemetry/instrumentation-connect": "0.56.0", + "@opentelemetry/instrumentation-dataloader": "0.30.0", + "@opentelemetry/instrumentation-express": "0.61.0", + "@opentelemetry/instrumentation-fs": "0.32.0", + "@opentelemetry/instrumentation-generic-pool": "0.56.0", + "@opentelemetry/instrumentation-graphql": "0.61.0", + "@opentelemetry/instrumentation-hapi": "0.59.0", + "@opentelemetry/instrumentation-http": "0.213.0", + "@opentelemetry/instrumentation-ioredis": "0.61.0", + "@opentelemetry/instrumentation-kafkajs": "0.22.0", + "@opentelemetry/instrumentation-knex": "0.57.0", + "@opentelemetry/instrumentation-koa": "0.61.0", + "@opentelemetry/instrumentation-lru-memoizer": "0.57.0", + "@opentelemetry/instrumentation-mongodb": "0.66.0", + "@opentelemetry/instrumentation-mongoose": "0.59.0", + "@opentelemetry/instrumentation-mysql": "0.59.0", + "@opentelemetry/instrumentation-mysql2": "0.59.0", + "@opentelemetry/instrumentation-pg": "0.65.0", + "@opentelemetry/instrumentation-redis": "0.61.0", + "@opentelemetry/instrumentation-tedious": "0.32.0", + "@opentelemetry/instrumentation-undici": "0.23.0", + "@opentelemetry/resources": "^2.6.0", + "@opentelemetry/sdk-trace-base": "^2.6.0", + "@opentelemetry/semantic-conventions": "^1.40.0", + "@prisma/instrumentation": "7.4.2", + "@sentry/core": "10.46.0", + "@sentry/node-core": "10.46.0", + "@sentry/opentelemetry": "10.46.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.46.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.46.0.tgz", + "integrity": "sha512-gwLGXfkzmiCmUI1VWttyoZBaVp1ItpDKc8AV2mQblWPQGdLSD0c6uKV/FkU291yZA3rXsrLXVwcWoibwnjE2vw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.46.0", + "@sentry/opentelemetry": "10.46.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/resources": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/semantic-conventions": "^1.39.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/context-async-hooks": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/resources": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "@opentelemetry/semantic-conventions": { + "optional": true + } + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "10.46.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.46.0.tgz", + "integrity": "sha512-dzzV2ovruGsx9jzusGGr6cNPvMgYRu2BIrF8aMZ3rkQ1OpPJjPStqtA1l1fw0aoxHOxIjFU7ml4emF+xdmMl3g==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.46.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.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/@sentry/profiling-node": { + "version": "10.46.0", + "resolved": "https://registry.npmjs.org/@sentry/profiling-node/-/profiling-node-10.46.0.tgz", + "integrity": "sha512-dFdUqriSQazkohJFr9oV2bxvr2j0y0YLeDyQLgzbWU+LcuK+zNgGR4OHVvfuPJA1TJHSztQaObPJ6aBVsgQ3ag==", + "license": "MIT", + "dependencies": { + "@sentry-internal/node-cpu-profiler": "^2.2.0", + "@sentry/core": "10.46.0", + "@sentry/node": "10.46.0" + }, + "bin": { + "sentry-prune-profiler-binaries": "scripts/prune-profiler-binaries.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.1.tgz", + "integrity": "sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@stellar/js-xdr": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", + "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@stellar/stellar-base": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-14.1.0.tgz", + "integrity": "sha512-A8kFli6QGy22SRF45IjgPAJfUNGjnI+R7g4DF5NZYVsD1kGf7B4ITyc4OPclLV9tqNI4/lXxafGEw0JEUbHixw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.9.6", + "@stellar/js-xdr": "^3.1.2", + "base32.js": "^0.1.0", + "bignumber.js": "^9.3.1", + "buffer": "^6.0.3", + "sha.js": "^2.4.12" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stellar/stellar-sdk": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-14.6.1.tgz", + "integrity": "sha512-A1rQWDLdUasXkMXnYSuhgep+3ZZzyuXJKdt5/KAIc0gkmSp906HTvUpbT4pu+bVr41tu0+J4Ugz9J4BQAGGytg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stellar/stellar-base": "^14.1.0", + "axios": "^1.13.3", + "bignumber.js": "^9.3.1", + "commander": "^14.0.2", + "eventsource": "^2.0.2", + "feaxios": "^0.0.23", + "randombytes": "^2.1.0", + "toml": "^3.0.0", + "urijs": "^1.19.1" + }, + "bin": { + "stellar-js": "bin/stellar-js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/auth-js": { + "version": "2.100.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.100.1.tgz", "integrity": "sha512-c5FB4nrG7cs1mLSzFGuIVl2iR2YO5XkSJ96uF4zubYm8YDn71XOi2emE9sBm/avfGCj61jaRBLOvxEAVnpys0Q==", "license": "MIT", "dependencies": { @@ -1722,6 +2551,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/archiver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz", + "integrity": "sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/readdir-glob": "*" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1814,7 +2653,6 @@ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -1887,6 +2725,12 @@ "pretty-format": "^30.0.0" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", @@ -1912,12 +2756,29 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/mysql": { + "version": "2.15.27", + "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", + "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "20.19.37", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1939,6 +2800,26 @@ "@types/node": "*" } }, + "node_modules/@types/pg": { + "version": "8.15.6", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", + "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/pg-pool": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.7.tgz", + "integrity": "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==", + "license": "MIT", + "dependencies": { + "@types/pg": "*" + } + }, "node_modules/@types/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", @@ -1951,6 +2832,16 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "license": "MIT" }, + "node_modules/@types/readdir-glob": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", + "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -2015,11 +2906,14 @@ "@types/superagent": "^8.1.0" } }, - "node_modules/@types/triple-beam": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", - "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", - "license": "MIT" + "node_modules/@types/tedious": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", + "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } }, "node_modules/@types/uuid": { "version": "10.0.0", @@ -2098,7 +2992,6 @@ "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", @@ -2573,6 +3466,18 @@ "win32" ] }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -2590,9 +3495,7 @@ "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", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2600,6 +3503,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", @@ -2669,7 +3581,6 @@ "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" @@ -2682,7 +3593,6 @@ "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" @@ -2701,24 +3611,98 @@ "dev": true, "license": "ISC", "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" }, "engines": { - "node": ">= 8" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "license": "MIT", - "engines": { - "node": ">=8.6" + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, "node_modules/arg": { @@ -2798,6 +3782,20 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-jest": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", @@ -2901,12 +3899,102 @@ "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/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.6.tgz", + "integrity": "sha512-1QovqDrR80Pmt5HPAsMsXTCFcDYr+NSUKW6nd6WO5v0JBmnItc/irNRzm2KOQ5oZ69P37y+AMujNyNtG+1Rggw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.4.tgz", + "integrity": "sha512-4JboWUl7/2LhgU536tjUszzaVC8/WEWKtyX5crayvlN71ih8+O2SdvBhotQeDsuhhmPZmLCrPBJEcwVPhI/kkQ==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.11.0.tgz", + "integrity": "sha512-Y/+iQ49fL3rIn6w/AVxI/2+BRrpmzJvdWt5Jv8Za6Ngqc6V227c+pYjYYgLdpR3MwQ9ObVXD0ZrqoBztakM0rw==", + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz", + "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base32.js": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", @@ -2921,7 +4009,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -2961,7 +4048,6 @@ "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -2980,6 +4066,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bip39": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bip39/-/bip39-3.1.0.tgz", + "integrity": "sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A==", + "license": "ISC", + "dependencies": { + "@noble/hashes": "^1.2.0" + } + }, "node_modules/bn.js": { "version": "4.12.3", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", @@ -3014,7 +4109,6 @@ "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -3056,7 +4150,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3098,7 +4191,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, "funding": [ { "type": "github", @@ -3119,6 +4211,15 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -3129,9 +4230,19 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -3189,6 +4300,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3315,7 +4432,6 @@ "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==", - "dev": true, "license": "MIT" }, "node_modules/cliui": { @@ -3423,24 +4539,10 @@ "dev": true, "license": "MIT" }, - "node_modules/color": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", - "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", - "license": "MIT", - "dependencies": { - "color-convert": "^3.1.3", - "color-string": "^2.1.3" - }, - "engines": { - "node": ">=18" - } - }, "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" @@ -3453,51 +4555,8 @@ "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/color-string": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", - "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", - "license": "MIT", - "dependencies": { - "color-name": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/color-string/node_modules/color-name": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", - "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", - "license": "MIT", - "engines": { - "node": ">=12.20" - } - }, - "node_modules/color/node_modules/color-convert": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", - "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", - "license": "MIT", - "dependencies": { - "color-name": "^2.0.0" - }, - "engines": { - "node": ">=14.6" - } - }, - "node_modules/color/node_modules/color-name": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", - "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", - "license": "MIT", - "engines": { - "node": ">=12.20" - } - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3531,13 +4590,59 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "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/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -3602,6 +4707,53 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -3613,7 +4765,6 @@ "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", @@ -3624,6 +4775,21 @@ "node": ">= 8" } }, + "node_modules/csv-parse": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.2.1.tgz", + "integrity": "sha512-LRLMV+UCyfMokp8Wb411duBf1gaBKJfOfBWU9eHMJ+b+cJYZsNu3AFmjJf3+yPGd59Exz1TsMjaSFyxnYB9+IQ==", + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3710,6 +4876,15 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -3745,7 +4920,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" @@ -3794,7 +4968,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, "license": "MIT" }, "node_modules/ecdsa-sig-formatter": { @@ -3836,13 +5009,6 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/enabled": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", - "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", "license": "MIT" }, "node_modules/encodeurl": { @@ -3946,7 +5112,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4183,7 +5348,6 @@ "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" @@ -4198,6 +5362,33 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", @@ -4315,7 +5506,6 @@ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", "license": "MIT", - "peer": true, "dependencies": { "ip-address": "10.1.0" }, @@ -4338,6 +5528,12 @@ "node": ">=6.6.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "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", @@ -4345,6 +5541,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "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", @@ -4414,11 +5616,28 @@ "is-retry-allowed": "^3.0.0" } }, - "node_modules/fecha": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", - "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", - "license": "MIT" + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } }, "node_modules/file-entry-cache": { "version": "6.0.1", @@ -4576,12 +5795,6 @@ "dev": true, "license": "ISC" }, - "node_modules/fn.name": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", - "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", - "license": "MIT" - }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -4623,7 +5836,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -4673,7 +5885,19 @@ "mime-db": "1.52.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 0.6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" } }, "node_modules/formidable": { @@ -4703,6 +5927,12 @@ "node": ">= 0.6" } }, + "node_modules/forwarded-parse": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", + "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", + "license": "MIT" + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -4716,7 +5946,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -4743,6 +5972,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4828,7 +6085,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -4862,14 +6118,12 @@ "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/glob/node_modules/brace-expansion": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -4879,7 +6133,6 @@ "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.2" @@ -4920,6 +6173,82 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-auth-library/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis": { + "version": "171.4.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-171.4.0.tgz", + "integrity": "sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.2.0", + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz", + "integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^7.0.0-rc.4", + "google-auth-library": "^10.1.0", + "qs": "^6.7.0", + "url-template": "^2.0.8" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4936,7 +6265,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -5090,6 +6418,54 @@ "node": ">=10.17.0" } }, + "node_modules/ical-generator": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/ical-generator/-/ical-generator-9.0.0.tgz", + "integrity": "sha512-Ohpcw5fN8LhrnKyEQwISP7/EYfoafy0Nb1ISIMET0GWdsHrWpiqIdLocSi5vrYAdjF8QKy1GkaSk/n+GfzR/8A==", + "license": "MIT", + "engines": { + "node": "20 || >=22.0.0" + }, + "peerDependencies": { + "@touch4it/ical-timezones": ">=1.6.0", + "@types/luxon": ">= 1.26.0", + "@types/mocha": ">= 8.2.1", + "dayjs": ">= 1.10.0", + "luxon": ">= 1.26.0", + "moment": ">= 2.29.0", + "moment-timezone": ">= 0.5.33", + "rrule": ">= 2.6.8" + }, + "peerDependenciesMeta": { + "@touch4it/ical-timezones": { + "optional": true + }, + "@types/luxon": { + "optional": true + }, + "@types/mocha": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-timezone": { + "optional": true + }, + "rrule": { + "optional": true + } + } + }, "node_modules/iceberg-js": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", @@ -5119,7 +6495,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -5173,6 +6548,21 @@ "node": ">=4" } }, + "node_modules/import-in-the-middle": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.0.tgz", + "integrity": "sha512-OnGy+eYT7wVejH2XWgLRgbmzujhhVIATQH0ztIeRilwHBjTeG3pD+XnH3PKX0r9gJ0BuJmJ68q/oh9qgXnNDQg==", + "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/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -5208,7 +6598,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -5302,7 +6691,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5409,7 +6797,6 @@ "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/istanbul-lib-coverage": { @@ -5487,7 +6874,6 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -5505,7 +6891,6 @@ "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", @@ -6113,6 +7498,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -6216,12 +7610,54 @@ "json-buffer": "3.0.1" } }, - "node_modules/kuler": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", - "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -6269,6 +7705,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -6281,6 +7730,13 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -6319,29 +7775,18 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, - "node_modules/logform": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", - "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", - "license": "MIT", - "dependencies": { - "@colors/colors": "1.6.0", - "@types/triple-beam": "^1.3.2", - "fecha": "^4.2.0", - "ms": "^2.1.1", - "safe-stable-stringify": "^2.3.1", - "triple-beam": "^1.3.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6490,7 +7935,6 @@ "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.2" @@ -6515,7 +7959,6 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -6534,12 +7977,80 @@ "node": ">=10" } }, + "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.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -6579,6 +8090,18 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-cron": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", @@ -6600,6 +8123,44 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -6627,7 +8188,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6679,15 +8239,6 @@ "wrappy": "1" } }, - "node_modules/one-time": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", - "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", - "license": "MIT", - "dependencies": { - "fn.name": "1.x.x" - } - }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -6704,6 +8255,13 @@ "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==", + "license": "MIT", + "peer": true + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6726,7 +8284,6 @@ "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" @@ -6768,7 +8325,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/parent-module": { @@ -6826,7 +8382,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6836,7 +8391,6 @@ "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" @@ -6853,7 +8407,6 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -6870,7 +8423,6 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, "license": "ISC" }, "node_modules/path-to-regexp": { @@ -6883,6 +8435,37 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6896,7 +8479,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6993,6 +8575,45 @@ "node": ">= 0.4" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7031,6 +8652,21 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -7181,6 +8817,42 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/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==", + "license": "MIT" + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -7233,6 +8905,19 @@ "node": ">=0.10.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/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -7415,15 +9100,6 @@ ], "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", @@ -7434,7 +9110,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7537,7 +9212,6 @@ "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" @@ -7550,7 +9224,6 @@ "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" @@ -7632,7 +9305,6 @@ "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" @@ -7679,15 +9351,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/stack-trace": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", - "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -7720,6 +9383,25 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -7770,7 +9452,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -7789,7 +9470,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -7804,7 +9484,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7814,14 +9493,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7834,7 +9511,6 @@ "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" @@ -7851,7 +9527,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7864,7 +9539,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7975,6 +9649,96 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-jsdoc": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", + "license": "MIT", + "dependencies": { + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "7.1.6", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/swagger-jsdoc/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==", + "license": "MIT" + }, + "node_modules/swagger-jsdoc/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/swagger-jsdoc/node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/swagger-jsdoc/node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-jsdoc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "10.0.3" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/synckit": { "version": "0.11.12", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", @@ -7991,6 +9755,27 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tar-stream": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -8059,11 +9844,14 @@ "node": "*" } }, - "node_modules/text-hex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", - "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", - "license": "MIT" + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } }, "node_modules/text-table": { "version": "0.2.0", @@ -8150,15 +9938,6 @@ "tree-kill": "cli.js" } }, - "node_modules/triple-beam": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", - "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", - "license": "MIT", - "engines": { - "node": ">= 14.0.0" - } - }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -8244,7 +10023,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -8422,13 +10200,18 @@ "node": ">= 0.4" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8549,6 +10332,12 @@ "dev": true, "license": "MIT" }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -8590,6 +10379,15 @@ "node": ">=10.12.0" } }, + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -8649,11 +10447,19 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 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" @@ -8687,42 +10493,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/winston": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", - "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", - "license": "MIT", - "dependencies": { - "@colors/colors": "^1.6.0", - "@dabh/diagnostics": "^2.0.8", - "async": "^3.2.3", - "is-stream": "^2.0.0", - "logform": "^2.7.0", - "one-time": "^1.0.0", - "readable-stream": "^3.4.0", - "safe-stable-stringify": "^2.3.1", - "stack-trace": "0.0.x", - "triple-beam": "^1.3.0", - "winston-transport": "^4.9.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/winston-transport": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", - "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", - "license": "MIT", - "dependencies": { - "logform": "^2.7.0", - "readable-stream": "^3.6.2", - "triple-beam": "^1.3.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -8744,7 +10514,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -8763,7 +10532,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -8781,7 +10549,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8791,14 +10558,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -8813,7 +10578,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -8826,7 +10590,6 @@ "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" @@ -8880,7 +10643,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4" @@ -8903,6 +10665,15 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -8991,7 +10762,6 @@ "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" @@ -9000,6 +10770,66 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/backend/package.json b/backend/package.json index ef38f56..2855342 100644 --- a/backend/package.json +++ b/backend/package.json @@ -45,7 +45,8 @@ "redis": "^5.11.0", "uuid": "^13.0.0", "web-push": "^3.6.7", - "winston": "^3.14.0", + "bip39": "^3.1.0", + "swagger-jsdoc": "^6.2.8", "zod": "^3.23.8" }, "devDependencies": { diff --git a/backend/src/errors/index.ts b/backend/src/errors/index.ts new file mode 100644 index 0000000..5122904 --- /dev/null +++ b/backend/src/errors/index.ts @@ -0,0 +1,80 @@ +/** + * Base class for all application errors that should be returned to the client + * following the RFC 7807 Problem Details for HTTP APIs format. + */ +export class AppError extends Error { + constructor( + public title: string, + public status: number, + public detail: string, + public type: string = 'about:blank', + public extensions?: Record + ) { + super(detail); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} + +/** + * Thrown when a requested resource is not found (HTTP 404). + */ +export class NotFoundError extends AppError { + constructor(detail: string) { + super('Not Found', 404, detail, 'https://syncro.app/errors/not-found'); + } +} + +/** + * Thrown when request input fails validation (HTTP 422). + */ +export class ValidationError extends AppError { + constructor(detail: string, public errors?: Record) { + super('Validation Error', 422, detail, 'https://syncro.app/errors/validation', { errors }); + } +} + +/** + * Thrown when authentication is required or fails (HTTP 401). + */ +export class UnauthorizedError extends AppError { + constructor(detail: string = 'Authentication required.') { + super('Unauthorized', 401, detail, 'https://syncro.app/errors/unauthorized'); + } +} + +/** + * Thrown when the user is authenticated but lacks permission (HTTP 403). + */ +export class ForbiddenError extends AppError { + constructor(detail: string = 'Access denied.') { + super('Forbidden', 403, detail, 'https://syncro.app/errors/forbidden'); + } +} + +/** + * Thrown when a request conflicts with the current state of the server (HTTP 409). + */ +export class ConflictError extends AppError { + constructor(detail: string) { + super('Conflict', 409, detail, 'https://syncro.app/errors/conflict'); + } +} + +/** + * Thrown for general client errors (HTTP 400). + */ +export class BadRequestError extends AppError { + constructor(detail: string, public extensions?: Record) { + super('Bad Request', 400, detail, 'https://syncro.app/errors/bad-request', extensions); + } +} + +/** + * Thrown when too many requests are sent (HTTP 429). + */ +export class RateLimitError extends AppError { + constructor(detail: string, public retryAfter: number) { + super('Too Many Requests', 429, detail, 'https://syncro.app/errors/too-many-requests', { retryAfter }); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index fc3a58a..bdb2aee 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4,9 +4,20 @@ import dotenv from 'dotenv'; import * as Sentry from '@sentry/node'; import { nodeProfilingIntegration } from '@sentry/profiling-node'; import swaggerUi from 'swagger-ui-express'; +import * as bip39 from 'bip39'; + // Load environment variables before importing other modules dotenv.config(); +// Sentry Initialization +Sentry.init({ + dsn: process.env.SENTRY_DSN, + environment: process.env.NODE_ENV || 'development', + integrations: [nodeProfilingIntegration()], + tracesSampleRate: 0.1, + profilesSampleRate: 0.1, +}); + import logger from './config/logger'; import { requestIdMiddleware } from './middleware/requestContext'; import { requestLoggerMiddleware } from './middleware/requestLogger'; @@ -23,6 +34,11 @@ import webhookRoutes from './routes/webhooks'; import complianceRoutes from './routes/compliance'; import tagsRoutes from './routes/tags'; import apiKeysRoutes from './routes/api-keys'; +import digestRoutes from './routes/digest'; +import mfaRoutes from './routes/mfa'; +import pushNotificationRoutes from './routes/push-notifications'; +import gmailRouter from '../routes/integrations/gmail' +import outlookRouter from '../routes/integrations/outlook' import { createExchangeRatesRouter } from './routes/exchange-rates'; import { ExchangeRateService } from './services/exchange-rate/exchange-rate-service'; import { FiatRateProvider } from './services/exchange-rate/fiat-provider'; @@ -31,191 +47,30 @@ import { monitoringService } from './services/monitoring-service'; import { healthService } from './services/health-service'; import { eventListener } from './services/event-listener'; import { expiryService } from './services/expiry-service'; -import gmailRouter from '../routes/integrations/gmail' -import outlookRouter from '../routes/integrations/outlook' import { authenticate } from './middleware/auth' +import { adminAuth } from './middleware/admin'; +import { createAdminLimiter, RateLimiterFactory } from './middleware/rate-limit-factory'; import { scheduleAutoResume } from './jobs/auto-resume'; +import { errorHandler } from './middleware/errorHandler'; +import { swaggerSpec } from './swagger'; const app = express(); const PORT = process.env.PORT || 3001; -const ADMIN_API_KEY = process.env.ADMIN_API_KEY || 'development-admin-key'; +// Validate Admin API Key +const ADMIN_API_KEY = process.env.ADMIN_API_KEY; +if (!ADMIN_API_KEY && process.env.NODE_ENV === 'production') { + throw new Error('ADMIN_API_KEY environment variable is required in production.'); +} + +// Exchange Rate Service Setup const exchangeRateService = new ExchangeRateService([ new FiatRateProvider(), new CryptoRateProvider(), ]); -// CORS configuration -const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000'; -app.use((req, res, next) => { - res.header('Access-Control-Allow-Origin', FRONTEND_URL); - res.header('Access-Control-Allow-Credentials', 'true'); - res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); - res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Idempotency-Key, If-Match'); - - if (req.method === 'OPTIONS') { - return res.sendStatus(200); - } - next(); -}); - -// Middleware -app.use(cookieParser()); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); - -// Request tracing — must come before routes so every log line carries requestId -app.use(requestIdMiddleware); -app.use(requestLoggerMiddleware); - -import { adminAuth } from './middleware/admin'; -import { createAdminLimiter, RateLimiterFactory } from './middleware/rate-limit-factory'; - -// Health check endpoint -app.get('/health', (req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); -}); - -// API Routes -app.use('/api/keys', apiKeysRoutes); -app.use('/api/subscriptions', subscriptionRoutes); -app.use('/api/risk-score', riskScoreRoutes); -app.use('/api/simulation', simulationRoutes); -app.use('/api/merchants', merchantRoutes); -app.use('/api/team', teamRoutes); -app.use('/api/audit', auditRoutes); -app.use('/api/integrations/gmail', authenticate, gmailRouter) -app.use('/api/integrations/outlook', authenticate, outlookRouter) -app.use('/api/webhooks', webhookRoutes); -app.use('/api/compliance', complianceRoutes); -app.use('/api/tags', tagsRoutes); -app.use('/api', tagsRoutes); // handles /api/subscriptions/:id/notes and /api/subscriptions/:id/tags -app.use('/api/exchange-rates', createExchangeRatesRouter(exchangeRateService)); - -// API Routes (Public/Standard) -app.get('/api/reminders/status', (req, res) => { - const status = schedulerService.getStatus(); - res.json(status); -}); - -// Admin Monitoring Endpoints (Read-only) -app.get('/api/admin/metrics/subscriptions', createAdminLimiter(), adminAuth, async (req, res) => { - try { - const metrics = await monitoringService.getSubscriptionMetrics(); - res.json(metrics); - } catch (error) { - res.status(500).json({ error: 'Failed to fetch subscription metrics' }); - } -}); - -app.get('/api/admin/metrics/renewals', createAdminLimiter(), adminAuth, async (req, res) => { - try { - const metrics = await monitoringService.getRenewalMetrics(); - res.json(metrics); - } catch (error) { - res.status(500).json({ error: 'Failed to fetch renewal metrics' }); - } -}); - -app.get('/api/admin/metrics/activity', createAdminLimiter(), adminAuth, async (req, res) => { - try { - const metrics = await monitoringService.getAgentActivity(); - res.json(metrics); - } catch (error) { - res.status(500).json({ error: 'Failed to fetch agent activity' }); - } -}); - -// Protocol Health Monitor: unified admin health (metrics, alerts, history) -app.get('/api/admin/health', createAdminLimiter(), adminAuth, async (req, res) => { - try { - const includeHistory = req.query.history !== 'false'; - const health = await healthService.getAdminHealth(includeHistory); - const health = await healthService.getAdminHealth(includeHistory, eventListener.getHealth()); - const statusCode = health.status === 'unhealthy' ? 503 : 200; - res.status(statusCode).json(health); - } catch (error) { - logger.error('Error fetching admin health:', error); - res.status(500).json({ error: 'Failed to fetch health status' }); - } -}); - -// Manual trigger endpoints (admin-protected) -app.post('/api/reminders/process', adminAuth, async (req, res) => { -// Manual trigger endpoints (for testing/admin - Should eventually be protected) -app.post('/api/reminders/process', createAdminLimiter(), adminAuth, async (req, res) => { - try { - await reminderEngine.processReminders(); - res.json({ success: true, message: 'Reminders processed' }); - } catch (error) { - logger.error('Error processing reminders:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : String(error), - }); - } -}); - -app.post('/api/reminders/schedule', createAdminLimiter(), adminAuth, async (req, res) => { - try { - const daysBefore = req.body.daysBefore || [7, 3, 1]; - await reminderEngine.scheduleReminders(daysBefore); - res.json({ success: true, message: 'Reminders scheduled' }); - } catch (error) { - logger.error('Error scheduling reminders:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : String(error), - }); - } -}); - -app.post('/api/reminders/retry', createAdminLimiter(), adminAuth, async (req, res) => { - try { - await reminderEngine.processRetries(); - res.json({ success: true, message: 'Retries processed' }); - } catch (error) { - logger.error('Error processing retries:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : String(error), - }); - } -}); -import * as bip39 from 'bip39'; - -import logger from './config/logger'; -import { requestIdMiddleware } from './middleware/requestContext'; -import { requestLoggerMiddleware } from './middleware/requestLogger'; -import { schedulerService } from './services/scheduler'; -import { reminderEngine } from './services/reminder-engine'; -import subscriptionRoutes from './routes/subscriptions'; -import riskScoreRoutes from './routes/risk-score'; -import simulationRoutes from './routes/simulation'; -import merchantRoutes from './routes/merchants'; -import teamRoutes from './routes/team'; -import auditRoutes from './routes/audit'; -import digestRoutes from './routes/digest'; -import mfaRoutes from './routes/mfa'; -import pushNotificationRoutes from './routes/push-notifications'; -import webhookRoutes from './routes/webhooks'; -import { monitoringService } from './services/monitoring-service'; -import { healthService } from './services/health-service'; -import { eventListener } from './services/event-listener'; -import { expiryService } from './services/expiry-service'; -import { swaggerSpec } from './swagger'; - -const app = express(); -const PORT = process.env.PORT || 3001; -const ADMIN_API_KEY = process.env.ADMIN_API_KEY || 'development-admin-key'; -import { scheduleAutoResume } from './jobs/auto-resume'; -const ADMIN_API_KEY = process.env.ADMIN_API_KEY; -if (!ADMIN_API_KEY) { - throw new Error( - 'ADMIN_API_KEY environment variable is required. ' + - 'Please set it to a strong random value and restart the server.' - ); -} +// Sentry Request Handler +app.use(Sentry.Handlers.requestHandler()); // CORS configuration const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000'; @@ -231,25 +86,21 @@ app.use((req, res, next) => { next(); }); -// Middleware +// Basic Middlewares app.use(cookieParser()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); -// Request tracing — must come before routes so every log line carries requestId +// Request context and logging app.use(requestIdMiddleware); app.use(requestLoggerMiddleware); - -import { adminAuth } from './middleware/admin'; -import { createAdminLimiter, RateLimiterFactory } from './middleware/rate-limit-factory'; - -// Health check endpoint +// Public Endpoints app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); -// Swagger UI — available in all environments +// Swagger Documentation app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); app.get('/api/docs.json', (_req, res) => { res.setHeader('Content-Type', 'application/json'); @@ -257,230 +108,29 @@ app.get('/api/docs.json', (_req, res) => { }); // API Routes +app.use('/api/keys', apiKeysRoutes); app.use('/api/subscriptions', subscriptionRoutes); app.use('/api/risk-score', riskScoreRoutes); app.use('/api/simulation', simulationRoutes); app.use('/api/merchants', merchantRoutes); app.use('/api/team', teamRoutes); app.use('/api/audit', auditRoutes); +app.use('/api/integrations/gmail', authenticate, gmailRouter); +app.use('/api/integrations/outlook', authenticate, outlookRouter); +app.use('/api/webhooks', webhookRoutes); +app.use('/api/compliance', complianceRoutes); +app.use('/api/tags', tagsRoutes); app.use('/api/digest', digestRoutes); app.use('/api/mfa', mfaRoutes); app.use('/api/notifications/push', pushNotificationRoutes); +app.use('/api/exchange-rates', createExchangeRatesRouter(exchangeRateService)); -// API Routes (Public/Standard) -app.use('/api/webhooks', webhookRoutes); -app.get('/api/reminders/status', (req, res) => { - const status = schedulerService.getStatus(); - res.json(status); -}); -// Admin Monitoring Endpoints (Read-only) -app.get('/api/admin/metrics/subscriptions', createAdminLimiter(), adminAuth, async (req, res) => { - try { - const metrics = await monitoringService.getSubscriptionMetrics(); - res.json(metrics); - } catch (error) { - res.status(500).json({ error: 'Failed to fetch subscription metrics' }); - } -}); -app.get('/api/admin/metrics/renewals', createAdminLimiter(), adminAuth, async (req, res) => { - try { - const metrics = await monitoringService.getRenewalMetrics(); - res.json(metrics); - } catch (error) { - res.status(500).json({ error: 'Failed to fetch renewal metrics' }); - } -}); -app.get('/api/admin/metrics/activity', createAdminLimiter(), adminAuth, async (req, res) => { - try { - const metrics = await monitoringService.getAgentActivity(); - res.json(metrics); - } catch (error) { - res.status(500).json({ error: 'Failed to fetch agent activity' }); - } -}); -// Protocol Health Monitor: unified admin health (metrics, alerts, history) -app.get('/api/admin/health', createAdminLimiter(), adminAuth, async (req, res) => { - try { - const includeHistory = req.query.history !== 'false'; - const health = await healthService.getAdminHealth(includeHistory); - const statusCode = health.status === 'unhealthy' ? 503 : 200; - res.status(statusCode).json(health); - } catch (error) { - logger.error('Error fetching admin health:', error); - res.status(500).json({ error: 'Failed to fetch health status' }); - } -}); -// Manual trigger endpoints (for testing/admin - Should eventually be protected) -app.post('/api/reminders/process', createAdminLimiter(), adminAuth, async (req, res) => { - try { - await reminderEngine.processReminders(); - res.json({ success: true, message: 'Reminders processed' }); - } catch (error) { - logger.error('Error processing reminders:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : String(error), - }); - } -}); -app.post('/api/reminders/schedule', createAdminLimiter(), adminAuth, async (req, res) => { - try { - const daysBefore = req.body.daysBefore || [7, 3, 1]; - await reminderEngine.scheduleReminders(daysBefore); - res.json({ success: true, message: 'Reminders scheduled' }); - } catch (error) { - logger.error('Error scheduling reminders:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : String(error), - }); - } -}); -app.post('/api/reminders/retry', createAdminLimiter(), adminAuth, async (req, res) => { - try { - await reminderEngine.processRetries(); - res.json({ success: true, message: 'Retries processed' }); - } catch (error) { - logger.error('Error processing retries:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : String(error), - }); - } -}); -// Protocol Health Monitor: record metrics snapshot periodically (historical storage) -const HEALTH_SNAPSHOT_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes -function startHealthSnapshotInterval() { - setInterval(() => { - healthService.recordSnapshot().catch(() => {}); - }, HEALTH_SNAPSHOT_INTERVAL_MS); - // Record one snapshot shortly after startup - setTimeout(() => healthService.recordSnapshot().catch(() => {}), 5000); -/** - * @openapi - * /api/reminders/status: - * get: - * tags: [Reminders] - * summary: Get reminder scheduler status - * responses: - * 200: - * description: Scheduler status object - */ -app.get('/api/reminders/status', (req, res) => { - const status = schedulerService.getStatus(); - res.json(status); -}); - -// Admin Monitoring Endpoints (Read-only) -/** - * @openapi - * /api/admin/metrics/subscriptions: - * get: - * tags: [Admin] - * summary: Get subscription metrics - * security: - * - adminKey: [] - * responses: - * 200: - * description: Subscription metrics - * 401: - * description: Unauthorized - */ -app.get('/api/admin/metrics/subscriptions', adminAuth, async (req, res) => { - try { - const metrics = await monitoringService.getSubscriptionMetrics(); - res.json(metrics); - } catch (error) { - res.status(500).json({ error: 'Failed to fetch subscription metrics' }); - } -}); - -// Load environment variables before importing other modules -dotenv.config(); - -Sentry.init({ - dsn: process.env.SENTRY_DSN, - environment: process.env.NODE_ENV, - integrations: [nodeProfilingIntegration()], - tracesSampleRate: 0.1, - profilesSampleRate: 0.1, -}); - - -import logger from './config/logger'; -import { requestIdMiddleware } from './middleware/requestContext'; -import { requestLoggerMiddleware } from './middleware/requestLogger'; -import { schedulerService } from './services/scheduler'; -import { reminderEngine } from './services/reminder-engine'; -import subscriptionRoutes from './routes/subscriptions'; -import riskScoreRoutes from './routes/risk-score'; -import simulationRoutes from './routes/simulation'; -import merchantRoutes from './routes/merchants'; -import teamRoutes from './routes/team'; -import auditRoutes from './routes/audit'; -import webhookRoutes from './routes/webhooks'; -import { monitoringService } from './services/monitoring-service'; -import { healthService } from './services/health-service'; -import { eventListener } from './services/event-listener'; -import { expiryService } from './services/expiry-service'; -import { scheduleAutoResume } from './jobs/auto-resume'; - -const app = express(); - -// Add Sentry request handler before routes -app.use(Sentry.Handlers.requestHandler()); - -const PORT = process.env.PORT || 3001; -const ADMIN_API_KEY = process.env.ADMIN_API_KEY || 'development-admin-key'; - -// CORS configuration -const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000'; -app.use((req, res, next) => { - res.header('Access-Control-Allow-Origin', FRONTEND_URL); - res.header('Access-Control-Allow-Credentials', 'true'); - res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); - res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Idempotency-Key, If-Match'); - - if (req.method === 'OPTIONS') { - return res.sendStatus(200); - } - next(); -}); - -// Middleware -app.use(cookieParser()); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); - -// Request tracing — must come before routes so every log line carries requestId -app.use(requestIdMiddleware); -app.use(requestLoggerMiddleware); - - -import { adminAuth } from './middleware/admin'; -import { createAdminLimiter, RateLimiterFactory } from './middleware/rate-limit-factory'; - -// Health check endpoint -app.get('/health', (req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); -}); - -// API Routes -app.use('/api/subscriptions', subscriptionRoutes); -app.use('/api/risk-score', riskScoreRoutes); -app.use('/api/simulation', simulationRoutes); -app.use('/api/merchants', merchantRoutes); -app.use('/api/team', teamRoutes); -app.use('/api/audit', auditRoutes); -app.use('/api/webhooks', webhookRoutes); - -// API Routes (Public/Standard) app.get('/api/reminders/status', (req, res) => { const status = schedulerService.getStatus(); res.json(status); }); -// Admin Monitoring Endpoints (Read-only) +// Admin Monitoring Endpoints app.get('/api/admin/metrics/subscriptions', createAdminLimiter(), adminAuth, async (req, res) => { try { const metrics = await monitoringService.getSubscriptionMetrics(); @@ -508,11 +158,10 @@ app.get('/api/admin/metrics/activity', createAdminLimiter(), adminAuth, async (r } }); -// Protocol Health Monitor: unified admin health (metrics, alerts, history) app.get('/api/admin/health', createAdminLimiter(), adminAuth, async (req, res) => { try { const includeHistory = req.query.history !== 'false'; - const health = await healthService.getAdminHealth(includeHistory); + const health = await healthService.getAdminHealth(includeHistory, eventListener.getHealth()); const statusCode = health.status === 'unhealthy' ? 503 : 200; res.status(statusCode).json(health); } catch (error) { @@ -521,17 +170,14 @@ app.get('/api/admin/health', createAdminLimiter(), adminAuth, async (req, res) = } }); -// Manual trigger endpoints (for testing/admin - Should eventually be protected) +// Admin Process Triggers app.post('/api/reminders/process', createAdminLimiter(), adminAuth, async (req, res) => { try { await reminderEngine.processReminders(); res.json({ success: true, message: 'Reminders processed' }); } catch (error) { logger.error('Error processing reminders:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : String(error), - }); + res.status(500).json({ success: false, error: 'Failed to process reminders' }); } }); @@ -542,10 +188,7 @@ app.post('/api/reminders/schedule', createAdminLimiter(), adminAuth, async (req, res.json({ success: true, message: 'Reminders scheduled' }); } catch (error) { logger.error('Error scheduling reminders:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : String(error), - }); + res.status(500).json({ success: false, error: 'Failed to schedule reminders' }); } }); @@ -555,252 +198,50 @@ app.post('/api/reminders/retry', createAdminLimiter(), adminAuth, async (req, re res.json({ success: true, message: 'Retries processed' }); } catch (error) { logger.error('Error processing retries:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : String(error), - }); + res.status(500).json({ success: false, error: 'Failed to process retries' }); } }); -// Protocol Health Monitor: record metrics snapshot periodically (historical storage) -const HEALTH_SNAPSHOT_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes -function startHealthSnapshotInterval() { - setInterval(() => { - healthService.recordSnapshot().catch(() => {}); - }, HEALTH_SNAPSHOT_INTERVAL_MS); - // Record one snapshot shortly after startup - setTimeout(() => healthService.recordSnapshot().catch(() => {}), 5000); -} - app.post('/api/admin/expiry/process', createAdminLimiter(), adminAuth, async (req, res) => { try { const result = await expiryService.processExpiries(); res.json({ success: true, data: result }); } catch (error) { logger.error('Error processing expiries:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : String(error), - }); + res.status(500).json({ success: false, error: 'Failed to process expiries' }); } }); -// Add Sentry error handler after all routes +// Error Handlers app.use(Sentry.Handlers.errorHandler()); +app.use(errorHandler); -// Start server -const server = app.listen(PORT, async () => { - logger.info(`Server running on port ${PORT}`); - logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`); - - // Initialize rate limiting Redis store - try { - await RateLimiterFactory.initializeRedisStore(); - logger.info('Rate limiting initialized successfully'); - } catch (error) { - logger.warn('Rate limiting initialization failed, using memory store:', error); -import * as bip39 from 'bip39'; -/** - * @openapi - * /api/admin/metrics/renewals: - * get: - * tags: [Admin] - * summary: Get renewal metrics - * security: - * - adminKey: [] - * responses: - * 200: - * description: Renewal metrics - * 401: - * description: Unauthorized - */ +// Helper Functions (Mnemonic) export function generateMnemonic(): string { return bip39.generateMnemonic(128); -app.get('/api/admin/metrics/renewals', adminAuth, async (req, res) => { - try { - const metrics = await monitoringService.getRenewalMetrics(); - res.json(metrics); - } catch (error) { - res.status(500).json({ error: 'Failed to fetch renewal metrics' }); - } -}); - -/** - * @openapi - * /api/admin/metrics/activity: - * get: - * tags: [Admin] - * summary: Get agent activity metrics - * security: - * - adminKey: [] - * responses: - * 200: - * description: Agent activity - * 401: - * description: Unauthorized - */ -app.get('/api/admin/metrics/activity', adminAuth, async (req, res) => { - try { - const metrics = await monitoringService.getAgentActivity(); - res.json(metrics); - } catch (error) { - res.status(500).json({ error: 'Failed to fetch agent activity' }); - } -}); - -/** - * @openapi - * /api/admin/health: - * get: - * tags: [Admin] - * summary: Get unified admin health status - * security: - * - adminKey: [] - * parameters: - * - in: query - * name: history - * schema: { type: boolean, default: true } - * responses: - * 200: - * description: Healthy - * 401: - * description: Unauthorized - * 503: - * description: Unhealthy - */ -app.get('/api/admin/health', adminAuth, async (req, res) => { - try { - const includeHistory = req.query.history !== 'false'; - const health = await healthService.getAdminHealth(includeHistory); - const statusCode = health.status === 'unhealthy' ? 503 : 200; - res.status(statusCode).json(health); - } catch (error) { - logger.error('Error fetching admin health:', error); - res.status(500).json({ error: 'Failed to fetch health status' }); - } -}); - -/** - * @openapi - * /api/reminders/process: - * post: - * tags: [Reminders] - * summary: Manually process reminders (admin) - * security: - * - adminKey: [] - * responses: - * 200: - * description: Reminders processed - * 401: - * description: Unauthorized - */ -app.post('/api/reminders/process', adminAuth, async (req, res) => { - try { - await reminderEngine.processReminders(); - res.json({ success: true, message: 'Reminders processed' }); - } catch (error) { - logger.error('Error processing reminders:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : String(error), - }); - } -}); - -/** - * @openapi - * /api/reminders/schedule: - * post: - * tags: [Reminders] - * summary: Schedule reminders (admin) - * security: - * - adminKey: [] - * requestBody: - * content: - * application/json: - * schema: - * type: object - * properties: - * daysBefore: - * type: array - * items: { type: integer } - * default: [7, 3, 1] - * responses: - * 200: - * description: Reminders scheduled - * 401: - * description: Unauthorized - */ -app.post('/api/reminders/schedule', adminAuth, async (req, res) => { - try { - const daysBefore = req.body.daysBefore || [7, 3, 1]; - await reminderEngine.scheduleReminders(daysBefore); - res.json({ success: true, message: 'Reminders scheduled' }); - } catch (error) { - logger.error('Error scheduling reminders:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : String(error), - }); - } -}); - -/** - * @openapi - * /api/reminders/retry: - * post: - * tags: [Reminders] - * summary: Process reminder retries (admin) - * security: - * - adminKey: [] - * responses: - * 200: - * description: Retries processed - * 401: - * description: Unauthorized - */ -app.post('/api/reminders/retry', adminAuth, async (req, res) => { - try { - await reminderEngine.processRetries(); - res.json({ success: true, message: 'Retries processed' }); - } catch (error) { - logger.error('Error processing retries:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : String(error), - }); - } -}); +} +export function validateMnemonic(mnemonic: string): boolean { + if (!mnemonic || typeof mnemonic !== 'string') return false; + const words = mnemonic.trim().split(/\s+/); + if (words.length !== 12) return false; + return bip39.validateMnemonic(words.join(' ')); +} -// Protocol Health Monitor: record metrics snapshot periodically (historical storage) -const HEALTH_SNAPSHOT_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes +// Health Metrics Snapshot Loop +const HEALTH_SNAPSHOT_INTERVAL_MS = 15 * 60 * 1000; function startHealthSnapshotInterval() { setInterval(() => { healthService.recordSnapshot().catch(() => {}); }, HEALTH_SNAPSHOT_INTERVAL_MS); - // Record one snapshot shortly after startup setTimeout(() => healthService.recordSnapshot().catch(() => {}), 5000); } -app.post('/api/admin/expiry/process', createAdminLimiter(), adminAuth, async (req, res) => { - try { - const result = await expiryService.processExpiries(); - res.json({ success: true, data: result }); - } catch (error) { - logger.error('Error processing expiries:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : String(error), - }); - } -}); - -// Start server +// Start Server const server = app.listen(PORT, async () => { logger.info(`Server running on port ${PORT}`); logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`); - // Validate critical env vars at startup — warn clearly so operators know what's missing + // Validation const criticalEnvVars = ['SOROBAN_CONTRACT_ADDRESS', 'STELLAR_NETWORK_URL']; for (const envVar of criticalEnvVars) { if (!process.env[envVar]) { @@ -808,59 +249,19 @@ const server = app.listen(PORT, async () => { } } - // Initialize rate limiting Redis store + // Initializations try { await RateLimiterFactory.initializeRedisStore(); logger.info('Rate limiting initialized successfully'); } catch (error) { logger.warn('Rate limiting initialization failed, using memory store:', error); -/** - * @openapi - * /api/admin/expiry/process: - * post: - * tags: [Admin] - * summary: Manually process subscription expiries (admin) - * security: - * - adminKey: [] - * responses: - * 200: - * description: Expiries processed - * 401: - * description: Unauthorized - */ -app.post('/api/admin/expiry/process', adminAuth, async (req, res) => { - try { - const result = await expiryService.processExpiries(); - res.json({ success: true, data: result }); - } catch (error) { - logger.error('Error processing expiries:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : String(error), - }); -import * as bip39 from 'bip39'; - * Generates a standard BIP39 12-word mnemonic phrase. -export function generateMnemonic(): string { - return bip39.generateMnemonic(128); - * Validates a 12-word BIP39 mnemonic phrase. -export function validateMnemonic(mnemonic: string): boolean { - if (!mnemonic || typeof mnemonic !== 'string') { - return false; - } - - const words = mnemonic.trim().split(/\s+/); - if (words.length !== 12) { - return false; } - // Start health metrics snapshot loop startHealthSnapshotInterval(); - - // Start event listener (no-op if disabled due to missing config) await eventListener.start(); const elHealth = eventListener.getHealth(); if (elHealth.status === 'disabled') { - logger.warn('EventListener is disabled', { reason: elHealth.reason }); + logger.warn('EventListener is disabled'); } else { logger.info('EventListener started', { status: elHealth.status }); } @@ -868,27 +269,16 @@ export function validateMnemonic(mnemonic: string): boolean { scheduleAutoResume(); }); - - // Graceful shutdown -process.on('SIGTERM', () => { - logger.info('SIGTERM received, shutting down gracefully'); +const shutdown = () => { + logger.info('Shutting down gracefully'); schedulerService.stop(); eventListener.stop(); server.close(() => { logger.info('Server closed'); process.exit(0); }); -}); +}; -process.on('SIGINT', () => { - logger.info('SIGINT received, shutting down gracefully'); - schedulerService.stop(); - eventListener.stop(); - server.close(() => { - logger.info('Server closed'); - process.exit(0); - }); -}); - return bip39.validateMnemonic(words.join(' ')); -} +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts new file mode 100644 index 0000000..28d925e --- /dev/null +++ b/backend/src/middleware/errorHandler.ts @@ -0,0 +1,49 @@ +import { Request, Response, NextFunction } from 'express'; +import { AppError } from '../errors'; +import logger from '../config/logger'; + +/** + * Global error handler middleware following RFC 7807 Problem Details. + */ +export const errorHandler = ( + err: Error, + req: Request, + res: Response, + next: NextFunction +) => { + const requestId = (res.getHeader('x-request-id') || req.headers['x-request-id']) as string; + const instance = req.path; + + if (err instanceof AppError) { + return res.status(err.status).json({ + type: err.type, + title: err.title, + status: err.status, + detail: err.detail, + instance, + requestId, + ...err.extensions, + }); + } + + // Unexpected errors + logger.error('Unhandled server error:', { + message: err.message, + stack: err.stack, + requestId, + path: req.path, + method: req.method, + }); + + // Don't leak internals in production + res.status(500).json({ + type: 'https://syncro.app/errors/internal', + title: 'Internal Server Error', + status: 500, + detail: process.env.NODE_ENV === 'production' + ? 'An unexpected error occurred.' + : err.message, + instance, + requestId, + }); +}; diff --git a/backend/src/routes/api-keys.ts b/backend/src/routes/api-keys.ts index 29375c0..a0b7dc9 100644 --- a/backend/src/routes/api-keys.ts +++ b/backend/src/routes/api-keys.ts @@ -1,32 +1,25 @@ import { Router, Response } from 'express'; +import { z } from 'zod'; import crypto from 'crypto'; import { supabase } from '../config/database'; import { authenticate, AuthenticatedRequest, requireScope } from '../middleware/auth'; -import logger from '../config/logger'; +import { validateRequest } from '../utils/validation'; +import { NotFoundError, BadRequestError } from '../errors'; const router = Router(); - -// All endpoints are for authenticated users (JWT or API key edit rights via user auth). router.use(authenticate); -const VALID_SCOPES = new Set(["subscriptions:read", "subscriptions:write", "webhooks:write", "analytics:read"]); - -function normalizeScopes(scopes: unknown): string[] { - if (Array.isArray(scopes)) { - return scopes - .map((scope) => String(scope || '').trim()) - .filter((scope) => scope && VALID_SCOPES.has(scope)); - } +const VALID_SCOPES = ['subscriptions:read', 'subscriptions:write', 'webhooks:write', 'analytics:read'] as const; - if (typeof scopes === 'string') { - return scopes - .split(',') - .map((scope) => scope.trim()) - .filter((scope) => scope && VALID_SCOPES.has(scope)); - } +const createApiKeySchema = z.object({ + name: z.string().min(1, 'Service name is required').max(100), + scopes: z.union([ + z.array(z.enum(VALID_SCOPES)), + z.string().transform((val) => val.split(',').map((s) => s.trim()) as any[]), + ]).refine((val) => val.length > 0, { message: 'At least one valid scope is required' }), +}); - return []; -} +// ─── Helpers ────────────────────────────────────────────────────────────────── function generateApiKey(): { key: string; hash: string } { const key = `sk_${crypto.randomBytes(32).toString('hex')}`; @@ -34,146 +27,90 @@ function generateApiKey(): { key: string; hash: string } { return { key, hash }; } +// ─── Routes ─────────────────────────────────────────────────────────────────── + +/** + * POST /api/api-keys + */ router.post('/', requireScope('subscriptions:write'), async (req: AuthenticatedRequest, res: Response) => { - try { - if (!req.user?.id) { - return res.status(401).json({ error: 'Unauthorized' }); - } - - const { name, scopes } = req.body || {}; - - const serviceName = String(name || 'default').trim(); - if (!serviceName) { - return res.status(400).json({ error: 'service name is required' }); - } - - const normalizedScopes = normalizeScopes(scopes); - if (normalizedScopes.length === 0) { - return res.status(400).json({ error: 'at least one valid scope is required' }); - } - - const { key, hash } = generateApiKey(); - - let insertResult: any; - try { - insertResult = await supabase.from('api_keys').insert([ - { - user_id: req.user.id, - service_name: serviceName, - key_hash: hash, - scopes: normalizedScopes, - revoked: false, - last_used_at: null, - request_count: 0, - }, - ]); - } catch (dbError) { - logger.error('insert call threw', dbError); - throw dbError; - } - - const error = (insertResult as any).error; - - if (error) { - logger.error('Failed to create API key', { error }); - return res.status(500).json({ error: 'Failed to create API key' }); - } - - console.log('about to send success response'); - return res.status(201).json({ success: true, key, scopes: normalizedScopes }); - } catch (error) { - logger.error('Create API key error:', error); - console.error('Create API key error:', error); - return res.status(500).json({ error: String(error) || 'Internal server error' }); - } + const { name, scopes } = validateRequest(createApiKeySchema, req.body); + const { key, hash } = generateApiKey(); + + const { error } = await supabase.from('api_keys').insert([ + { + user_id: req.user!.id, + service_name: name, + key_hash: hash, + scopes, + revoked: false, + last_used_at: null, + request_count: 0, + }, + ]); + + if (error) throw error; + + res.status(201).json({ success: true, key, scopes }); }); +/** + * GET /api/api-keys + */ router.get('/', requireScope('subscriptions:read'), async (req: AuthenticatedRequest, res: Response) => { - try { - if (!req.user?.id) { - return res.status(401).json({ error: 'Unauthorized' }); - } - - const { data, error } = await supabase - .from('api_keys') - .select('id, service_name, scopes, revoked, created_at, updated_at, last_used_at, request_count') - .eq('user_id', req.user.id) - .order('created_at', { ascending: false }); - - if (error) { - logger.error('Failed to list API keys', { error }); - return res.status(500).json({ error: 'Failed to list API keys' }); - } - - return res.json({ success: true, data }); - } catch (error) { - logger.error('List API keys error:', error); - return res.status(500).json({ error: 'Internal server error' }); - } + const { data, error } = await supabase + .from('api_keys') + .select('id, service_name, scopes, revoked, created_at, updated_at, last_used_at, request_count') + .eq('user_id', req.user!.id) + .order('created_at', { ascending: false }); + + if (error) throw error; + + res.json({ success: true, data }); }); +/** + * DELETE /api/api-keys/:id + * Revoke an API key + */ router.delete('/:id', requireScope('subscriptions:write'), async (req: AuthenticatedRequest, res: Response) => { - try { - if (!req.user?.id) { - return res.status(401).json({ error: 'Unauthorized' }); - } - - const keyId = req.params.id; - - const { data: existingKey, error: fetchError } = await supabase - .from('api_keys') - .select('id') - .eq('id', keyId) - .eq('user_id', req.user.id) - .single(); - - if (fetchError || !existingKey) { - return res.status(404).json({ error: 'API key not found' }); - } - - const { error } = await supabase - .from('api_keys') - .update({ revoked: true, updated_at: new Date().toISOString() }) - .eq('id', keyId) - .eq('user_id', req.user.id); - - if (error) { - logger.error('Failed to revoke API key', { error }); - return res.status(500).json({ error: 'Failed to revoke API key' }); - } - - return res.json({ success: true }); - } catch (error) { - logger.error('Revoke API key error:', error); - return res.status(500).json({ error: 'Internal server error' }); + const { data: existingKey, error: fetchError } = await supabase + .from('api_keys') + .select('id') + .eq('id', req.params.id) + .eq('user_id', req.user!.id) + .maybeSingle(); + + if (fetchError || !existingKey) { + throw new NotFoundError('API key not found'); } + + const { error } = await supabase + .from('api_keys') + .update({ revoked: true, updated_at: new Date().toISOString() }) + .eq('id', req.params.id) + .eq('user_id', req.user!.id); + + if (error) throw error; + + res.json({ success: true, message: 'API key revoked' }); }); +/** + * GET /api/api-keys/:id/usage + */ router.get('/:id/usage', requireScope('subscriptions:read'), async (req: AuthenticatedRequest, res: Response) => { - try { - if (!req.user?.id) { - return res.status(401).json({ error: 'Unauthorized' }); - } - - const keyId = req.params.id; - - const { data, error } = await supabase - .from('api_keys') - .select('id, service_name, scopes, revoked, created_at, updated_at, last_used_at, request_count') - .eq('id', keyId) - .eq('user_id', req.user.id) - .single(); - - if (error || !data) { - logger.error('Failed to fetch API key usage', { error }); - return res.status(404).json({ error: 'API key not found' }); - } - - return res.json({ success: true, data }); - } catch (error) { - logger.error('API key usage error:', error); - return res.status(500).json({ error: 'Internal server error' }); + const { data, error } = await supabase + .from('api_keys') + .select('id, service_name, scopes, revoked, created_at, updated_at, last_used_at, request_count') + .eq('id', req.params.id) + .eq('user_id', req.user!.id) + .maybeSingle(); + + if (error || !data) { + throw new NotFoundError('API key not found'); } + + res.json({ success: true, data }); }); export default router; diff --git a/backend/src/routes/audit.ts b/backend/src/routes/audit.ts index 52ec643..b35bb03 100644 --- a/backend/src/routes/audit.ts +++ b/backend/src/routes/audit.ts @@ -1,29 +1,23 @@ import { Router, Request, Response } from 'express'; import { z } from 'zod'; -import { auditService, AuditEntry, AuditEventBatch } from '../services/audit-service'; +import { auditService, AuditEntry } from '../services/audit-service'; import { adminAuth } from '../middleware/admin'; -import logger from '../config/logger'; +import { validateRequest } from '../utils/validation'; +import { BadRequestError } from '../errors'; + +const router = Router(); // ─── Validation schemas ─────────────────────────────────────────────────────── const auditEventSchema = z.object({ - // Core identity fields - action: z.string().min(1).max(100, 'action must not exceed 100 characters'), - resource_type: z.string().min(1).max(100, 'resource_type must not exceed 100 characters'), - resource_id: z.string().max(255, 'resource_id must not exceed 255 characters').optional(), - - // Actor / session info - user_id: z.string().max(128, 'user_id must not exceed 128 characters').optional(), - session_id: z.string().max(128, 'session_id must not exceed 128 characters').optional(), - - // Contextual metadata (free-form but bounded) + action: z.string().min(1).max(100), + resource_type: z.string().min(1).max(100), + resource_id: z.string().max(255).optional(), + user_id: z.string().max(128).optional(), + session_id: z.string().max(128).optional(), metadata: z.record(z.unknown()).optional(), - - // Status / severity status: z.enum(['success', 'failure', 'pending']).optional(), severity: z.enum(['info', 'warn', 'error', 'critical']).optional(), - - // Timestamps — caller may supply; enrichment happens server-side timestamp: z.string().datetime({ offset: true }).optional(), }); @@ -34,166 +28,66 @@ const auditBatchSchema = z.object({ .max(100, 'maximum 100 events per batch'), }); +const auditQuerySchema = z.object({ + action: z.string().optional(), + resourceType: z.string().optional(), + userId: z.string().optional(), + limit: z.string().transform(Number).optional().default('100'), + offset: z.string().transform(Number).optional().default('0'), + startDate: z.string().datetime().optional(), + endDate: z.string().datetime().optional(), +}); -const router = Router(); +// ─── Routes ─────────────────────────────────────────────────────────────────── /** - * @openapi - * /api/audit: - * post: - * tags: [Audit] - * summary: Submit a batch of audit events - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [events] - * properties: - * events: - * type: array - * maxItems: 100 - * items: - * type: object - * properties: - * action: { type: string } - * resourceType: { type: string } - * resourceId: { type: string } - * userId: { type: string } - * metadata: { type: object } - * responses: - * 201: - * description: Events inserted - * content: - * application/json: - * schema: - * type: object - * properties: - * success: { type: boolean } - * inserted: { type: integer } - * failed: { type: integer } - * 400: - * description: Validation error - * get: - * tags: [Audit] - * summary: Retrieve audit logs (admin only) - * security: - * - adminKey: [] - * parameters: - * - in: query - * name: action - * schema: { type: string } - * - in: query - * name: resourceType - * schema: { type: string } - * - in: query - * name: userId - * schema: { type: string } - * - in: query - * name: limit - * schema: { type: integer, default: 100, maximum: 1000 } - * - in: query - * name: offset - * schema: { type: integer, default: 0 } - * - in: query - * name: startDate - * schema: { type: string, format: date-time } - * - in: query - * name: endDate - * schema: { type: string, format: date-time } - * responses: - * 200: - * description: Audit logs with pagination - * 401: - * description: Unauthorized + * POST /api/audit + * Submit a batch of audit events */ router.post('/', async (req: Request, res: Response) => { - try { - const bodyValidation = auditBatchSchema.safeParse(req.body); - if (!bodyValidation.success) { - return res.status(400).json({ - error: 'Invalid request: ' + bodyValidation.error.errors.map((e) => e.message).join(', '), - }); - } - - // Enrich events with request metadata - const enrichedEvents = bodyValidation.data.events.map((event) => ({ - ...event, - ipAddress: req.ip || req.connection.remoteAddress, - userAgent: req.get('user-agent') || undefined, - })); - - // Insert batch into database - const result = await auditService.insertBatch(enrichedEvents as AuditEntry[]); - - if (!result.success) { - logger.warn(`Audit batch insertion failed: ${result.errors.join(', ')}`); - return res.status(400).json({ - error: 'Failed to insert audit events', - details: result.errors, - }); - } - - // Log success - logger.info( - `Audit batch processed: ${result.inserted} inserted, ${result.failed} failed` - ); - - res.status(201).json({ - success: true, - inserted: result.inserted, - failed: result.failed, - errors: result.errors.length > 0 ? result.errors : undefined, - }); - } catch (error) { - logger.error('Error in POST /api/audit:', error); - res.status(500).json({ - error: 'Internal server error', - message: error instanceof Error ? error.message : 'Unknown error', - }); + const { events } = validateRequest(auditBatchSchema, req.body); + + // Enrich events with request metadata + const enrichedEvents = events.map((event: any) => ({ + ...event, + ipAddress: req.ip || (req.connection as any).remoteAddress, + userAgent: req.get('user-agent') || undefined, + })); + + const result = await auditService.insertBatch(enrichedEvents as AuditEntry[]); + + if (!result.success) { + throw new BadRequestError('Failed to insert audit events', { details: result.errors }); } -}); + res.status(201).json({ + success: true, + inserted: result.inserted, + failed: result.failed, + errors: result.errors.length > 0 ? result.errors : undefined, + }); +}); /** - * GET /api/admin/audit + * GET /api/audit * Retrieve audit logs (admin only) - * Query parameters: - * - action: filter by action - * - resourceType: filter by resource type - * - userId: filter by user ID - * - limit: number of results (default: 100, max: 1000) - * - offset: pagination offset (default: 0) - * - startDate: ISO8601 date string for start of range - * - endDate: ISO8601 date string for end of range */ router.get('/', adminAuth, async (req: Request, res: Response) => { - try { - const { - action, - resourceType, - userId, - limit = '100', - offset = '0', - startDate, - endDate, - } = req.query; - - // Validate and parse limit and offset - let parsedLimit = parseInt(limit as string, 10); - let parsedOffset = parseInt(offset as string, 10); - - if (isNaN(parsedLimit) || parsedLimit < 1 || parsedLimit > 1000) { - parsedLimit = 100; - } - - if (isNaN(parsedOffset) || parsedOffset < 0) { - parsedOffset = 0; - } - - // Query audit logs - const logs = await auditService.getAllLogs({ + const { + action, + resourceType, + userId, + limit, + offset, + startDate, + endDate, + } = validateRequest(auditQuerySchema, req.query, 'query'); + + const parsedLimit = Math.min(limit || 100, 1000); + const parsedOffset = Math.max(offset || 0, 0); + + const [logs, total] = await Promise.all([ + auditService.getAllLogs({ action: action as string | undefined, resourceType: resourceType as string | undefined, userId: userId as string | undefined, @@ -201,32 +95,24 @@ router.get('/', adminAuth, async (req: Request, res: Response) => { offset: parsedOffset, startDate: startDate as string | undefined, endDate: endDate as string | undefined, - }); - - // Get total count - const total = await auditService.getLogsCount({ + }), + auditService.getLogsCount({ action: action as string | undefined, resourceType: resourceType as string | undefined, userId: userId as string | undefined, - }); - - res.json({ - success: true, - data: logs, - pagination: { - limit: parsedLimit, - offset: parsedOffset, - total, - hasMore: parsedOffset + parsedLimit < total, - }, - }); - } catch (error) { - logger.error('Error in GET /api/admin/audit:', error); - res.status(500).json({ - error: 'Internal server error', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } + }), + ]); + + res.json({ + success: true, + data: logs, + pagination: { + limit: parsedLimit, + offset: parsedOffset, + total, + hasMore: parsedOffset + parsedLimit < total, + }, + }); }); export default router; diff --git a/backend/src/routes/compliance.ts b/backend/src/routes/compliance.ts index 2bba11b..7270f92 100644 --- a/backend/src/routes/compliance.ts +++ b/backend/src/routes/compliance.ts @@ -5,6 +5,7 @@ import { complianceService } from '../services/compliance-service'; import { supabase } from '../config/database'; import logger from '../config/logger'; import { RateLimiterFactory } from '../middleware/rate-limit-factory'; +import { BadRequestError, NotFoundError, ConflictError, UnauthorizedError } from '../errors'; const router = Router(); @@ -24,7 +25,7 @@ const exportRateLimit = RateLimiterFactory.createCustomLimiter({ endpointType: 'data-export', }); -// ─── HTML Renderers ────────────────────────────────────────────────────────── +// ─── HTML Renderers (Static) ────────────────────────────────────────────────── const BASE_STYLE = ` body { margin: 0; padding: 40px 16px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background: #f9fafb; color: #111827; } @@ -39,392 +40,159 @@ const BASE_STYLE = ` function renderConfirmPage(token: string, emailType: string): string { const friendlyType = escapeHtml(emailType.replace(/_/g, ' ')); - return ` - -Unsubscribe - - -
-

Unsubscribe

-

You are about to unsubscribe from ${friendlyType} emails. Click the button below to confirm.

-
- - -
-
- -`; + return `Unsubscribe

Unsubscribe

You are about to unsubscribe from ${friendlyType} emails. Click the button below to confirm.

`; } function renderSuccessPage(emailType: string): string { const friendlyType = escapeHtml(emailType.replace(/_/g, ' ')); - return ` - -Unsubscribed - - -
-
-

You've been unsubscribed

-

You will no longer receive ${friendlyType} emails from us. This change may take a short time to take effect.

-

If you unsubscribed by mistake, you can update your email preferences from your account settings.

-
- -`; + return `Unsubscribed

You've been unsubscribed

You will no longer receive ${friendlyType} emails from us.

`; } function renderErrorPage(message: string): string { - return ` - -Error - - -
-

Something went wrong

-

${escapeHtml(message)}

-
- -`; + return `Error

Something went wrong

${escapeHtml(message)}

`; } // ─── Token-based auth helper ───────────────────────────────────────────────── -/** - * Resolve user ID from either a token query/body param (HMAC unsubscribe token) - * or from standard Bearer/cookie session auth. - * Returns null if neither is valid. - */ -async function resolveUserFromTokenOrSession( - req: Request, - token?: string, -): Promise { +async function resolveUserFromTokenOrSession(req: Request, token?: string): Promise { if (token) { const result = complianceService.verifyUnsubscribeToken(token); - if (result.valid && result.userId) { - return result.userId; - } - return null; + return result.valid && result.userId ? result.userId : null; } - // Fall back to session auth const authHeader = req.headers.authorization; let sessionToken: string | null = null; - - if (authHeader && authHeader.startsWith('Bearer ')) { + if (authHeader?.startsWith('Bearer ')) { sessionToken = authHeader.substring(7); } else if ((req as any).cookies?.authToken) { sessionToken = (req as any).cookies.authToken; } - if (!sessionToken) return null; const { data: { user }, error } = await supabase.auth.getUser(sessionToken); - if (error || !user) return null; - return user.id; + return error || !user ? null : user.id; } // ─── Data Export ───────────────────────────────────────────────────────────── -/** - * GET /api/compliance/export - * Auth required. Streams a ZIP archive containing the user's data. - */ router.get('/export', authenticate, exportRateLimit, async (req: AuthenticatedRequest, res: Response) => { const userId = req.user!.id; - try { - const data = await complianceService.gatherUserData(userId); - - res.setHeader('Content-Type', 'application/zip'); - res.setHeader('Content-Disposition', `attachment; filename="syncro-data-export-${Date.now()}.zip"`); - - const archive = archiver('zip', { zlib: { level: 9 } }); - - archive.on('error', (err) => { - logger.error('Archiver error during export:', err); - // Headers already sent; cannot send a JSON error response here - }); - - archive.pipe(res); - - // 8 JSON files - archive.append(JSON.stringify(data.profile, null, 2), { name: 'profile.json' }); - archive.append(JSON.stringify(data.subscriptions, null, 2), { name: 'subscriptions.json' }); - archive.append(JSON.stringify(data.notifications, null, 2), { name: 'notifications.json' }); - archive.append(JSON.stringify(data.auditLogs, null, 2), { name: 'audit_logs.json' }); - archive.append(JSON.stringify(data.preferences, null, 2), { name: 'preferences.json' }); - archive.append(JSON.stringify(data.emailAccounts, null, 2), { name: 'email_accounts.json' }); - archive.append(JSON.stringify(data.teams, null, 2), { name: 'teams.json' }); - archive.append(JSON.stringify(data.blockchainLogs, null, 2), { name: 'blockchain_logs.json' }); - - // README - const readme = [ - 'Syncro — Personal Data Export', - '==============================', - `Generated: ${new Date().toISOString()}`, - `User ID: ${userId}`, - '', - 'Files included:', - ' profile.json — Your account profile', - ' subscriptions.json — All subscription records', - ' notifications.json — Notification history', - ' audit_logs.json — Account activity log', - ' preferences.json — User preferences and email settings', - ' email_accounts.json — Connected email accounts', - ' teams.json — Team membership records', - ' blockchain_logs.json — On-chain contract events and renewal approvals', - '', - 'For questions or deletion requests, contact support.', - ].join('\n'); - - archive.append(readme, { name: 'README.txt' }); - - await archive.finalize(); - - // Log to audit_logs after stream completes - await supabase.from('audit_logs').insert({ - user_id: userId, - action: 'data_export', - resource_type: 'account', - resource_id: userId, - metadata: { exported_at: new Date().toISOString() }, - }); - - logger.info(`Data export completed for user ${userId}`); - } catch (error) { - logger.error('Data export error:', error); - if (!res.headersSent) { - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to export data', - }); - } - } + const data = await complianceService.gatherUserData(userId); + + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="syncro-data-export-${Date.now()}.zip"`); + + const archive = archiver('zip', { zlib: { level: 9 } }); + archive.on('error', (err) => logger.error('Archiver error:', err)); + archive.pipe(res); + + // Append data files + archive.append(JSON.stringify(data.profile, null, 2), { name: 'profile.json' }); + archive.append(JSON.stringify(data.subscriptions, null, 2), { name: 'subscriptions.json' }); + archive.append(JSON.stringify(data.notifications, null, 2), { name: 'notifications.json' }); + archive.append(JSON.stringify(data.auditLogs, null, 2), { name: 'audit_logs.json' }); + archive.append(JSON.stringify(data.preferences, null, 2), { name: 'preferences.json' }); + archive.append(JSON.stringify(data.emailAccounts, null, 2), { name: 'email_accounts.json' }); + archive.append(JSON.stringify(data.teams, null, 2), { name: 'teams.json' }); + archive.append(JSON.stringify(data.blockchainLogs, null, 2), { name: 'blockchain_logs.json' }); + + await archive.finalize(); + + await supabase.from('audit_logs').insert({ + user_id: userId, + action: 'data_export', + resource_type: 'account', + resource_id: userId, + metadata: { exported_at: new Date().toISOString() }, + }); }); // ─── Account Deletion ───────────────────────────────────────────────────────── -/** - * POST /api/compliance/account/delete - * Auth required. Schedules account deletion in 30 days. - */ router.post('/account/delete', authenticate, async (req: AuthenticatedRequest, res: Response) => { - const userId = req.user!.id; try { - const { reason } = req.body; - const result = await complianceService.requestDeletion(userId, reason); + const result = await complianceService.requestDeletion(req.user!.id, req.body.reason); res.json({ success: true, data: result }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to request deletion'; - if (message.includes('already pending')) { - return res.status(409).json({ success: false, error: message }); - } - logger.error('Account deletion request error:', error); - res.status(500).json({ success: false, error: message }); + } catch (error: any) { + if (error.message?.includes('already pending')) throw new ConflictError(error.message); + throw error; } }); -/** - * POST /api/compliance/account/delete/cancel - * Auth required. Cancels a pending account deletion. - */ router.post('/account/delete/cancel', authenticate, async (req: AuthenticatedRequest, res: Response) => { - const userId = req.user!.id; - try { - const result = await complianceService.cancelDeletion(userId); - res.json({ success: true, data: result }); - } catch (error) { - logger.error('Cancel deletion error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to cancel deletion', - }); - } + const result = await complianceService.cancelDeletion(req.user!.id); + res.json({ success: true, data: result }); }); -/** - * GET /api/compliance/account/deletion-status - * Auth required. Returns deletion request status. - */ router.get('/account/deletion-status', authenticate, async (req: AuthenticatedRequest, res: Response) => { - const userId = req.user!.id; - try { - const status = await complianceService.getDeletionStatus(userId); - res.json({ success: true, data: status }); - } catch (error) { - logger.error('Deletion status error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to get deletion status', - }); - } + const status = await complianceService.getDeletionStatus(req.user!.id); + res.json({ success: true, data: status }); }); -// ─── Unsubscribe (no auth — accessed from email links) ─────────────────────── +// ─── Unsubscribe ────────────────────────────────────────────────────────────── -/** - * GET /api/compliance/unsubscribe?token=... - * Verifies HMAC token. Does NOT mutate state. Renders HTML confirmation page. - */ router.get('/unsubscribe', async (req: Request, res: Response) => { const token = req.query.token as string | undefined; - - if (!token) { - res.status(400).send(renderErrorPage('Missing unsubscribe token.')); - return; - } + if (!token) return res.status(400).send(renderErrorPage('Missing unsubscribe token.')); const result = complianceService.verifyUnsubscribeToken(token); - if (!result.valid || !result.emailType) { - res.status(400).send(renderErrorPage('This unsubscribe link is invalid or has expired.')); - return; - } + if (!result.valid || !result.emailType) return res.status(400).send(renderErrorPage('Invalid link.')); res.send(renderConfirmPage(token, result.emailType)); }); -/** - * POST /api/compliance/unsubscribe - * Verifies token from body, updates user_preferences.email_opt_ins. - */ router.post('/unsubscribe', async (req: Request, res: Response) => { const token = req.body.token as string | undefined; - - if (!token) { - res.status(400).send(renderErrorPage('Missing unsubscribe token.')); - return; - } + if (!token) return res.status(400).send(renderErrorPage('Missing unsubscribe token.')); const result = complianceService.verifyUnsubscribeToken(token); - if (!result.valid || !result.userId || !result.emailType) { - res.status(400).send(renderErrorPage('This unsubscribe link is invalid or has expired.')); - return; - } + if (!result.valid || !result.userId || !result.emailType) return res.status(400).send(renderErrorPage('Invalid link.')); - try { - const { data: prefs } = await supabase - .from('user_preferences') - .select('email_opt_ins') - .eq('user_id', result.userId) - .single(); - - const currentOptIns: Record = (prefs?.email_opt_ins as Record) || {}; - const updated = { ...currentOptIns, [result.emailType]: false }; - - const { error } = await supabase - .from('user_preferences') - .upsert({ user_id: result.userId, email_opt_ins: updated }, { onConflict: 'user_id' }); - - if (error) { - logger.error('Unsubscribe DB update error:', error); - res.status(500).send(renderErrorPage('Failed to update preferences. Please try again.')); - return; - } - - logger.info(`User ${result.userId} unsubscribed from ${result.emailType}`); - res.send(renderSuccessPage(result.emailType)); - } catch (error) { - logger.error('Unsubscribe error:', error); - res.status(500).send(renderErrorPage('An unexpected error occurred. Please try again.')); - } + const { data: prefs } = await supabase.from('user_preferences').select('email_opt_ins').eq('user_id', result.userId).single(); + const currentOptIns = (prefs?.email_opt_ins as Record) || {}; + const updated = { ...currentOptIns, [result.emailType]: false }; + + const { error } = await supabase.from('user_preferences').upsert({ user_id: result.userId, email_opt_ins: updated }, { onConflict: 'user_id' }); + if (error) throw error; + + res.send(renderSuccessPage(result.emailType)); }); -// ─── Email Preferences API (dual auth: token OR session) ───────────────────── +// ─── Email Preferences API ──────────────────────────────────────────────────── const KNOWN_OPT_IN_KEYS = ['reminders', 'marketing', 'updates', 'digests'] as const; -type OptInKey = typeof KNOWN_OPT_IN_KEYS[number]; -/** - * GET /api/compliance/email-preferences - * Returns current email_opt_ins. Accepts ?token=... or Bearer/cookie auth. - */ router.get('/email-preferences', async (req: Request, res: Response) => { const token = req.query.token as string | undefined; const userId = await resolveUserFromTokenOrSession(req, token); + if (!userId) throw new UnauthorizedError(); - if (!userId) { - res.status(401).json({ success: false, error: 'Unauthorized' }); - return; - } + const { data: prefs, error } = await supabase.from('user_preferences').select('email_opt_ins').eq('user_id', userId).maybeSingle(); + if (error) throw error; - try { - const { data: prefs, error } = await supabase - .from('user_preferences') - .select('email_opt_ins') - .eq('user_id', userId) - .single(); - - if (error && error.code !== 'PGRST116') { - throw error; - } - - res.json({ success: true, data: { email_opt_ins: prefs?.email_opt_ins ?? {} } }); - } catch (error) { - logger.error('Get email preferences error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to get email preferences', - }); - } + res.json({ success: true, data: { email_opt_ins: prefs?.email_opt_ins ?? {} } }); }); -/** - * PATCH /api/compliance/email-preferences - * Updates email_opt_ins. Accepts token in body or Bearer/cookie auth. - * Only allows known keys: reminders, marketing, updates, digests. - */ router.patch('/email-preferences', async (req: Request, res: Response) => { const token = req.body.token as string | undefined; const userId = await resolveUserFromTokenOrSession(req, token); + if (!userId) throw new UnauthorizedError(); - if (!userId) { - res.status(401).json({ success: false, error: 'Unauthorized' }); - return; - } - - // Extract only known keys from the request body (strip token field and unknown keys) - const updates: Partial> = {}; + const updates: Record = {}; for (const key of KNOWN_OPT_IN_KEYS) { - if (key in req.body && typeof req.body[key] === 'boolean') { - updates[key] = req.body[key]; - } + if (typeof req.body[key] === 'boolean') updates[key] = req.body[key]; } - if (Object.keys(updates).length === 0) { - res.status(400).json({ - success: false, - error: `No valid keys provided. Allowed keys: ${KNOWN_OPT_IN_KEYS.join(', ')}`, - }); - return; - } + if (Object.keys(updates).length === 0) throw new BadRequestError(`Allowed keys: ${KNOWN_OPT_IN_KEYS.join(', ')}`); - try { - const { data: prefs } = await supabase - .from('user_preferences') - .select('email_opt_ins') - .eq('user_id', userId) - .single(); - - const currentOptIns: Record = (prefs?.email_opt_ins as Record) || {}; - const merged = { ...currentOptIns, ...updates }; - - const { data, error } = await supabase - .from('user_preferences') - .upsert({ user_id: userId, email_opt_ins: merged }, { onConflict: 'user_id' }) - .select('email_opt_ins') - .single(); - - if (error) { - throw error; - } - - res.json({ success: true, data: { email_opt_ins: data?.email_opt_ins ?? merged } }); - } catch (error) { - logger.error('Update email preferences error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to update email preferences', - }); - } + const { data: prefs } = await supabase.from('user_preferences').select('email_opt_ins').eq('user_id', userId).single(); + const currentOptIns = (prefs?.email_opt_ins as Record) || {}; + const merged = { ...currentOptIns, ...updates }; + + const { data, error } = await supabase.from('user_preferences').upsert({ user_id: userId, email_opt_ins: merged }, { onConflict: 'user_id' }).select('email_opt_ins').single(); + if (error) throw error; + + res.json({ success: true, data: { email_opt_ins: data?.email_opt_ins ?? merged } }); }); export default router; diff --git a/backend/src/routes/digest.ts b/backend/src/routes/digest.ts index 33a6524..02ecb42 100644 --- a/backend/src/routes/digest.ts +++ b/backend/src/routes/digest.ts @@ -1,206 +1,81 @@ import { Router, Response } from 'express'; +import { z } from 'zod'; import { authenticate, AuthenticatedRequest } from '../middleware/auth'; import { adminAuth } from '../middleware/admin'; import { digestService } from '../services/digest-service'; import { digestEmailService } from '../services/digest-email-service'; -import logger from '../config/logger'; +import { validateRequest } from '../utils/validation'; +import { BadRequestError, RateLimitError } from '../errors'; const router = Router(); // ─── User-facing routes (authenticated) ────────────────────────────────────── - router.use(authenticate); +const updateDigestPreferencesSchema = z.object({ + digestEnabled: z.boolean().optional(), + digestDay: z.number().int().min(1).max(28).optional(), + includeYearToDate: z.boolean().optional(), +}); + /** - * @openapi - * /api/digest/preferences: - * get: - * tags: [Digest] - * summary: Get digest preferences - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: Digest preferences - * content: - * application/json: - * schema: - * type: object - * properties: - * success: { type: boolean } - * data: { $ref: '#/components/schemas/DigestPreferences' } - * 401: - * description: Unauthorized - * patch: - * tags: [Digest] - * summary: Update digest preferences - * security: - * - bearerAuth: [] - * requestBody: - * content: - * application/json: - * schema: - * type: object - * properties: - * digestEnabled: { type: boolean } - * digestDay: { type: integer, minimum: 1, maximum: 28 } - * includeYearToDate: { type: boolean } - * responses: - * 200: - * description: Updated preferences - * 400: - * description: Validation error - * 401: - * description: Unauthorized + * GET /api/digest/preferences */ router.get('/preferences', async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.id; - if (!userId) return res.status(401).json({ success: false, error: 'Unauthorized' }); - - const prefs = await digestService.getDigestPreferences(userId); - return res.json({ success: true, data: prefs }); - } catch (err) { - logger.error('GET /digest/preferences error:', err); - return res.status(500).json({ success: false, error: 'Failed to fetch preferences' }); - } + const prefs = await digestService.getDigestPreferences(req.user!.id); + res.json({ success: true, data: prefs }); }); /** * PATCH /api/digest/preferences - * Update digest settings (opt-in, digest day, year-to-date toggle). - * - * Body: { digestEnabled?, digestDay?, includeYearToDate? } */ router.patch('/preferences', async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.id; - if (!userId) return res.status(401).json({ success: false, error: 'Unauthorized' }); - - const { digestEnabled, digestDay, includeYearToDate } = req.body; - - if (digestDay !== undefined) { - const day = Number(digestDay); - if (!Number.isInteger(day) || day < 1 || day > 28) { - return res.status(400).json({ - success: false, - error: 'digestDay must be an integer between 1 and 28', - }); - } - } - - const updated = await digestService.updateDigestPreferences(userId, { - ...(digestEnabled !== undefined && { digestEnabled }), - ...(digestDay !== undefined && { digestDay: Number(digestDay) }), - ...(includeYearToDate !== undefined && { includeYearToDate }), - }); - - return res.json({ success: true, data: updated }); - } catch (err) { - logger.error('PATCH /digest/preferences error:', err); - return res.status(500).json({ success: false, error: 'Failed to update preferences' }); - } + const validatedData = validateRequest(updateDigestPreferencesSchema, req.body); + const updated = await digestService.updateDigestPreferences(req.user!.id, validatedData); + res.json({ success: true, data: updated }); }); /** - * @openapi - * /api/digest/test: - * post: - * tags: [Digest] - * summary: Send a test digest email (rate-limited to 1/hour) - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: Test digest sent - * 401: - * description: Unauthorized - * 429: - * description: Rate limit — already sent within the last hour + * POST /api/digest/test */ router.post('/test', async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.id; - if (!userId) return res.status(401).json({ success: false, error: 'Unauthorized' }); - - // Basic rate-limit: max 1 test per hour - const history = await digestEmailService.getAuditHistory(userId, 5); - const oneHourAgo = Date.now() - 60 * 60 * 1000; - const recentTests = history.filter( - (h) => h.digestType === 'test' && new Date(h.sentAt).getTime() > oneHourAgo, - ); + const userId = req.user!.id; - if (recentTests.length > 0) { - return res.status(429).json({ - success: false, - error: 'A test digest was already sent in the last hour. Please try again later.', - }); - } + // Basic rate-limit: max 1 test per hour + const history = await digestEmailService.getAuditHistory(userId, 5); + const oneHourAgo = Date.now() - 60 * 60 * 1000; + const recentTests = history.filter( + (h) => h.digestType === 'test' && new Date(h.sentAt).getTime() > oneHourAgo + ); - const outcome = await digestService.sendDigestForUser(userId, 'test'); - - if (!outcome.success) { - return res.status(500).json({ success: false, error: outcome.error }); - } + if (recentTests.length > 0) { + throw new RateLimitError('A test digest was already sent in the last hour.'); + } - return res.json({ success: true, message: 'Test digest sent successfully.' }); - } catch (err) { - logger.error('POST /digest/test error:', err); - return res.status(500).json({ success: false, error: 'Failed to send test digest' }); + const outcome = await digestService.sendDigestForUser(userId, 'test'); + if (!outcome.success) { + throw new BadRequestError(outcome.error || 'Failed to send test digest'); } + + res.json({ success: true, message: 'Test digest sent successfully.' }); }); /** - * @openapi - * /api/digest/history: - * get: - * tags: [Digest] - * summary: Get digest send history (last 24 records) - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: Digest history - * 401: - * description: Unauthorized + * GET /api/digest/history */ router.get('/history', async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.id; - if (!userId) return res.status(401).json({ success: false, error: 'Unauthorized' }); - - const history = await digestEmailService.getAuditHistory(userId); - return res.json({ success: true, data: history }); - } catch (err) { - logger.error('GET /digest/history error:', err); - return res.status(500).json({ success: false, error: 'Failed to fetch digest history' }); - } + const history = await digestEmailService.getAuditHistory(req.user!.id); + res.json({ success: true, data: history }); }); // ─── Admin routes ───────────────────────────────────────────────────────────── /** - * @openapi - * /api/digest/admin/run: - * post: - * tags: [Digest] - * summary: Manually trigger monthly digest run (admin only) - * security: - * - adminKey: [] - * responses: - * 200: - * description: Digest run result - * 401: - * description: Unauthorized + * POST /api/digest/admin/run */ router.post('/admin/run', adminAuth, async (_req, res: Response) => { - try { - const result = await digestService.runMonthlyDigest(); - return res.json({ success: true, data: result }); - } catch (err) { - logger.error('POST /digest/admin/run error:', err); - return res.status(500).json({ success: false, error: 'Digest run failed' }); - } + const result = await digestService.runMonthlyDigest(); + res.json({ success: true, data: result }); }); export default router; \ No newline at end of file diff --git a/backend/src/routes/exchange-rates.ts b/backend/src/routes/exchange-rates.ts index dfe4666..2c34d05 100644 --- a/backend/src/routes/exchange-rates.ts +++ b/backend/src/routes/exchange-rates.ts @@ -2,40 +2,29 @@ import { Router, Response } from 'express'; import { authenticate, AuthenticatedRequest } from '../middleware/auth'; import { isSupportedCurrency } from '../constants/currencies'; import { ExchangeRateService } from '../services/exchange-rate/exchange-rate-service'; -import logger from '../config/logger'; +import { BadRequestError } from '../errors'; export function createExchangeRatesRouter(exchangeRateService: ExchangeRateService): Router { const router = Router(); - router.use(authenticate); + /** + * GET /api/exchange-rates + */ router.get('/', async (req: AuthenticatedRequest, res: Response) => { - try { - const base = (req.query.base as string) || 'USD'; + const base = (req.query.base as string) || 'USD'; - if (!isSupportedCurrency(base)) { - return res.status(400).json({ - success: false, - error: `Unsupported currency: ${base}`, - meta: { timestamp: new Date().toISOString() }, - }); - } + if (!isSupportedCurrency(base)) { + throw new BadRequestError(`Unsupported currency: ${base}`); + } - const data = await exchangeRateService.getExchangeRateResponse(base); + const data = await exchangeRateService.getExchangeRateResponse(base); - res.json({ - success: true, - data, - meta: { timestamp: new Date().toISOString() }, - }); - } catch (error) { - logger.error('Exchange rates error:', error); - res.status(500).json({ - success: false, - error: 'Failed to fetch exchange rates', - meta: { timestamp: new Date().toISOString() }, - }); - } + res.json({ + success: true, + data, + meta: { timestamp: new Date().toISOString() }, + }); }); return router; diff --git a/backend/src/routes/merchants.ts b/backend/src/routes/merchants.ts index 10877b7..9b2b68d 100644 --- a/backend/src/routes/merchants.ts +++ b/backend/src/routes/merchants.ts @@ -1,10 +1,12 @@ import { Router, Response, Request } from 'express'; import { z } from 'zod'; import { merchantService } from '../services/merchant-service'; -import logger from '../config/logger'; import { adminAuth } from '../middleware/admin'; import { renewalRateLimiter } from '../middleware/rateLimiter'; -// import { renewalRateLimiter } from '../middleware/rate-limiter'; // Added Import +import { validateRequest } from '../utils/validation'; +import { BadRequestError } from '../errors'; + +const router = Router(); // ─── Validation schemas ─────────────────────────────────────────────────────── @@ -36,256 +38,89 @@ const createMerchantSchema = z.object({ const updateMerchantSchema = createMerchantSchema.partial(); - -const router = Router(); +// ─── Routes ─────────────────────────────────────────────────────────────────── /** - * @openapi - * /api/merchants: - * get: - * tags: [Merchants] - * summary: List merchants - * parameters: - * - in: query - * name: category - * schema: { type: string } - * - in: query - * name: limit - * schema: { type: integer, default: 20 } - * - in: query - * name: offset - * schema: { type: integer, default: 0 } - * responses: - * 200: - * description: List of merchants - * content: - * application/json: - * schema: - * type: object - * properties: - * success: { type: boolean } - * data: - * type: array - * items: { $ref: '#/components/schemas/Merchant' } - * pagination: { $ref: '#/components/schemas/Pagination' } + * GET /api/merchants + * List all merchants with optional filtering */ router.get('/', async (req: Request, res: Response) => { - try { - const { limit, offset, category } = req.query; - - const result = await merchantService.listMerchants({ - category: category as string | undefined, - limit: limit ? parseInt(limit as string) : undefined, - offset: offset ? parseInt(offset as string) : undefined, - }); - - res.json({ - success: true, - data: result.merchants, - pagination: { - total: result.total, - limit: limit ? parseInt(limit as string) : undefined, - offset: offset ? parseInt(offset as string) : undefined, - }, - }); - } catch (error) { - logger.error('List merchants error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to list merchants', - }); - } + const { limit, offset, category } = req.query; + + const limitNum = limit ? parseInt(limit as string, 10) : 20; + const offsetNum = offset ? parseInt(offset as string, 10) : 0; + + if (isNaN(limitNum) || limitNum < 1) throw new BadRequestError('Limit must be a positive integer'); + if (isNaN(offsetNum) || offsetNum < 0) throw new BadRequestError('Offset must be a non-negative integer'); + + const result = await merchantService.listMerchants({ + category: category as string | undefined, + limit: limitNum, + offset: offsetNum, + }); + + res.json({ + success: true, + data: result.merchants, + pagination: { + total: result.total, + limit: limitNum, + offset: offsetNum, + }, + }); }); /** - * @openapi - * /api/merchants/{id}: - * get: - * tags: [Merchants] - * summary: Get a merchant by ID - * parameters: - * - in: path - * name: id - * required: true - * schema: { type: string, format: uuid } - * responses: - * 200: - * description: Merchant object - * content: - * application/json: - * schema: - * type: object - * properties: - * success: { type: boolean } - * data: { $ref: '#/components/schemas/Merchant' } - * 404: - * description: Not found + * GET /api/merchants/:id + * Get a single merchant by ID */ router.get('/:id', async (req: Request, res: Response) => { - try { - const merchant = await merchantService.getMerchant(req.params.id as string); - - res.json({ - success: true, - data: merchant, - }); - } catch (error) { - logger.error('Get merchant error:', error); - const statusCode = error instanceof Error && error.message.includes('not found') ? 404 : 500; - res.status(statusCode).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to get merchant', - }); - } + const merchant = await merchantService.getMerchant(req.params.id); + res.json({ + success: true, + data: merchant, + }); }); /** - * @openapi - * /api/merchants: - * post: - * tags: [Merchants] - * summary: Create a merchant (admin only) - * security: - * - adminKey: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [name] - * properties: - * name: { type: string } - * category: { type: string } - * website_url: { type: string, format: uri } - * logo_url: { type: string, format: uri } - * responses: - * 201: - * description: Merchant created - * 400: - * description: Validation error - * 401: - * description: Unauthorized + * POST /api/merchants + * Create a new merchant (admin only) */ router.post('/', adminAuth, async (req: Request, res: Response) => { - try { - const validation = createMerchantSchema.safeParse(req.body); - if (!validation.success) { - return res.status(400).json({ - success: false, - error: validation.error.errors.map((e) => e.message).join(', '), - }); - } - - const merchant = await merchantService.createMerchant(validation.data); + const validatedData = validateRequest(createMerchantSchema, req.body); + const merchant = await merchantService.createMerchant(validatedData); - res.status(201).json({ - success: true, - data: merchant, - }); - } catch (error) { - logger.error('Create merchant error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to create merchant', - }); - } + res.status(201).json({ + success: true, + data: merchant, + }); }); /** - * @openapi - * /api/merchants/{id}: - * patch: - * tags: [Merchants] - * summary: Update a merchant (admin only) - * security: - * - adminKey: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: { type: string, format: uuid } - * requestBody: - * content: - * application/json: - * schema: - * type: object - * properties: - * name: { type: string } - * category: { type: string } - * website_url: { type: string, format: uri } - * logo_url: { type: string, format: uri } - * responses: - * 200: - * description: Updated merchant - * 401: - * description: Unauthorized - * 404: - * description: Not found - * delete: - * tags: [Merchants] - * summary: Delete a merchant (admin only) - * security: - * - adminKey: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: { type: string, format: uuid } - * responses: - * 200: - * description: Deleted - * 401: - * description: Unauthorized + * PATCH /api/merchants/:id + * Update a merchant (admin only) */ router.patch('/:id', adminAuth, renewalRateLimiter, async (req: Request, res: Response) => { -// router.patch('/:id', adminAuth, renewalRateLimiter, async (req: Request, res: Response) => { -// try { -// const merchant = await merchantService.updateMerchant(req.params.id as string, req.body); -router.patch('/:id', adminAuth, async (req: Request, res: Response) => { - try { - const validation = updateMerchantSchema.safeParse(req.body); - if (!validation.success) { - return res.status(400).json({ - success: false, - error: validation.error.errors.map((e) => e.message).join(', '), - }); - } + const validatedData = validateRequest(updateMerchantSchema, req.body); + const merchant = await merchantService.updateMerchant(req.params.id, validatedData); - const merchant = await merchantService.updateMerchant(req.params.id as string, validation.data); - -// res.json({ -// success: true, -// data: merchant, -// }); -// } catch (error) { -// logger.error('Update merchant error:', error); -// const statusCode = error instanceof Error && error.message.includes('not found') ? 404 : 500; -// res.status(statusCode).json({ -// success: false, -// error: error instanceof Error ? error.message : 'Failed to update merchant', -// }); -// } -// }); + res.json({ + success: true, + data: merchant, + }); +}); /** - * DELETE /api/merchants/:id — covered by PATCH doc block above + * DELETE /api/merchants/:id + * Delete a merchant (admin only) */ router.delete('/:id', adminAuth, async (req: Request, res: Response) => { - try { - await merchantService.deleteMerchant(req.params.id as string); + await merchantService.deleteMerchant(req.params.id); - res.json({ - success: true, - message: 'Merchant deleted', - }); - } catch (error) { - logger.error('Delete merchant error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to delete merchant', - }); - } + res.json({ + success: true, + message: 'Merchant deleted', + }); }); export default router; \ No newline at end of file diff --git a/backend/src/routes/mfa.ts b/backend/src/routes/mfa.ts index cc96be1..a1488a1 100644 --- a/backend/src/routes/mfa.ts +++ b/backend/src/routes/mfa.ts @@ -1,293 +1,104 @@ import { Router, Response } from 'express'; +import { z } from 'zod'; import { supabase } from '../config/database'; import { authenticate, AuthenticatedRequest } from '../middleware/auth'; import { recoveryCodeService } from '../services/mfa-service'; import { TotpRateLimiter } from '../lib/totp-rate-limiter'; import { createMfaLimiter } from '../middleware/rate-limit-factory'; import { emailService } from '../services/email-service'; -import logger from '../config/logger'; +import { validateRequest } from '../utils/validation'; +import { RateLimitError, UnauthorizedError, NotFoundError, ForbiddenError } from '../errors'; const router = Router(); const totpRateLimiter = new TotpRateLimiter(); - -// Apply authenticate middleware to all routes router.use(authenticate); +const notifySchema = z.object({ + event: z.enum(['enrolled', 'disabled']), +}); + +const require2faSchema = z.object({ + required: z.boolean(), +}); + /** - * @openapi - * /api/2fa/recovery-codes/generate: - * post: - * tags: [2FA] - * summary: Generate 10 recovery codes - * security: - * - bearerAuth: [] - * responses: - * 201: - * description: Recovery codes generated - * content: - * application/json: - * schema: - * type: object - * properties: - * success: { type: boolean } - * data: - * type: object - * properties: - * codes: - * type: array - * items: { type: string } - * 401: - * description: Unauthorized + * POST /api/2fa/recovery-codes/generate */ -router.post('/2fa/recovery-codes/generate', async (req: AuthenticatedRequest, res: Response) => { -// --------------------------------------------------------------------------- -// POST /api/2fa/recovery-codes/generate -// Generate 10 recovery codes for the authenticated user -// --------------------------------------------------------------------------- router.post('/2fa/recovery-codes/generate', createMfaLimiter(), async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user!.id; - const codes = await recoveryCodeService.generate(userId); - res.status(201).json({ success: true, data: { codes } }); - } catch (error) { - logger.error('POST /api/2fa/recovery-codes/generate error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to generate recovery codes', - }); - } + const codes = await recoveryCodeService.generate(req.user!.id); + res.status(201).json({ success: true, data: { codes } }); }); /** - * @openapi - * /api/2fa/recovery-codes/verify: - * post: - * tags: [2FA] - * summary: Verify a recovery code (rate-limited) - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [code] - * properties: - * code: { type: string } - * responses: - * 200: - * description: Code valid - * 400: - * description: code is required - * 401: - * description: Invalid or already-used recovery code - * 429: - * description: Too many failed attempts + * POST /api/2fa/recovery-codes/verify */ -router.post('/2fa/recovery-codes/verify', async (req: AuthenticatedRequest, res: Response) => { -// --------------------------------------------------------------------------- -// POST /api/2fa/recovery-codes/verify -// Verify a recovery code — rate-limited per session -// --------------------------------------------------------------------------- router.post('/2fa/recovery-codes/verify', createMfaLimiter(), async (req: AuthenticatedRequest, res: Response) => { - const sessionId = req.user!.id; + const userId = req.user!.id; + const { code } = validateRequest(z.object({ code: z.string().min(1) }), req.body); - if (totpRateLimiter.isLocked(sessionId)) { - return res.status(429).json({ - success: false, - error: 'Too many failed attempts. Please try again later.', - }); + if (totpRateLimiter.isLocked(userId)) { + throw new RateLimitError('Too many failed attempts. Please try again later.'); } - try { - const { code } = req.body as { code?: string }; - - if (!code) { - return res.status(400).json({ success: false, error: 'code is required' }); + const valid = await recoveryCodeService.verify(userId, code); + if (!valid) { + totpRateLimiter.recordFailure(userId); + if (totpRateLimiter.isLocked(userId)) { + throw new RateLimitError('Too many failed attempts. Please try again later.'); } - - const valid = await recoveryCodeService.verify(req.user!.id, code); - - if (!valid) { - totpRateLimiter.recordFailure(sessionId); - - // Re-check after recording — may have just hit the threshold - if (totpRateLimiter.isLocked(sessionId)) { - return res.status(429).json({ - success: false, - error: 'Too many failed attempts. Please try again later.', - }); - } - - return res.status(401).json({ success: false, error: 'Invalid or already-used recovery code' }); - } - - totpRateLimiter.reset(sessionId); - res.json({ success: true }); - } catch (error) { - logger.error('POST /api/2fa/recovery-codes/verify error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to verify recovery code', - }); + throw new UnauthorizedError('Invalid or already-used recovery code'); } + + totpRateLimiter.reset(userId); + res.json({ success: true }); }); /** - * @openapi - * /api/2fa/recovery-codes: - * delete: - * tags: [2FA] - * summary: Invalidate all recovery codes - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: All codes invalidated - * 401: - * description: Unauthorized + * DELETE /api/2fa/recovery-codes */ router.delete('/2fa/recovery-codes', async (req: AuthenticatedRequest, res: Response) => { - try { - await recoveryCodeService.invalidateAll(req.user!.id); - res.json({ success: true }); - } catch (error) { - logger.error('DELETE /api/2fa/recovery-codes error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to invalidate recovery codes', - }); - } + await recoveryCodeService.invalidateAll(req.user!.id); + res.json({ success: true }); }); /** - * @openapi - * /api/2fa/notify: - * post: - * tags: [2FA] - * summary: Send a 2FA lifecycle notification email - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [event] - * properties: - * event: { type: string, enum: [enrolled, disabled] } - * responses: - * 200: - * description: Notification queued - * 400: - * description: Invalid event value - * 401: - * description: Unauthorized + * POST /api/2fa/notify */ router.post('/2fa/notify', async (req: AuthenticatedRequest, res: Response) => { - const { event } = req.body as { event?: 'enrolled' | 'disabled' }; - - if (!event || (event !== 'enrolled' && event !== 'disabled')) { - return res.status(400).json({ success: false, error: "event must be 'enrolled' or 'disabled'" }); - } - + const { event } = validateRequest(notifySchema, req.body); const recipientEmail = req.user!.email; - const subject = - event === 'enrolled' - ? '2FA Enabled on your SYNCRO account' - : '2FA Disabled on your SYNCRO account'; - - const bodyText = - event === 'enrolled' - ? 'Two-factor authentication has been successfully enabled on your SYNCRO account.' - : 'Two-factor authentication has been disabled on your SYNCRO account. If you did not make this change, please contact support immediately.'; - // Fire-and-forget — email failures must not block the response - emailService - .sendSimpleEmail(recipientEmail, subject, bodyText) - .catch((err: unknown) => logger.error('2FA notification email failed:', err)); + const subject = event === 'enrolled' ? '2FA Enabled on your SYNCRO account' : '2FA Disabled on your SYNCRO account'; + const bodyText = event === 'enrolled' + ? 'Two-factor authentication has been successfully enabled on your SYNCRO account.' + : 'Two-factor authentication has been disabled on your SYNCRO account. If you did not make this change, please contact support immediately.'; + emailService.sendSimpleEmail(recipientEmail, subject, bodyText).catch(() => {}); res.json({ success: true }); }); /** - * @openapi - * /api/teams/{teamId}/require-2fa: - * put: - * tags: [2FA] - * summary: Set team 2FA enforcement policy (owner only) - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: teamId - * required: true - * schema: { type: string, format: uuid } - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [required] - * properties: - * required: { type: boolean } - * responses: - * 200: - * description: Policy updated - * 400: - * description: required must be boolean - * 401: - * description: Unauthorized - * 403: - * description: Only team owner can change this - * 404: - * description: Team not found + * PUT /api/teams/:teamId/require-2fa */ router.put('/teams/:teamId/require-2fa', async (req: AuthenticatedRequest, res: Response) => { + const { required } = validateRequest(require2faSchema, req.body); const { teamId } = req.params; - const { required } = req.body as { required?: boolean }; - if (typeof required !== 'boolean') { - return res.status(400).json({ success: false, error: 'required (boolean) is required' }); - } - - try { - // Verify the authenticated user is the owner of this team - const { data: team, error: teamErr } = await supabase - .from('teams') - .select('id, owner_id') - .eq('id', teamId) - .single(); + const { data: team, error: teamErr } = await supabase.from('teams').select('id, owner_id').eq('id', teamId).maybeSingle(); + if (teamErr || !team) throw new NotFoundError('Team not found'); + if (team.owner_id !== req.user!.id) throw new ForbiddenError('Only the team owner can change 2FA enforcement'); - if (teamErr || !team) { - return res.status(404).json({ success: false, error: 'Team not found' }); - } + const { error: updateErr } = await supabase + .from('teams') + .update({ + require_2fa: required, + require_2fa_set_at: required ? new Date().toISOString() : null, + }) + .eq('id', teamId); - if (team.owner_id !== req.user!.id) { - return res.status(403).json({ success: false, error: 'Only the team owner can change 2FA enforcement' }); - } + if (updateErr) throw updateErr; - const { error: updateErr } = await supabase - .from('teams') - .update({ - require_2fa: required, - require_2fa_set_at: required ? new Date().toISOString() : null, - }) - .eq('id', teamId); - - if (updateErr) throw updateErr; - - res.json({ success: true, data: { teamId, require2fa: required } }); - } catch (error) { - logger.error('PUT /api/teams/:teamId/require-2fa error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to update team 2FA enforcement', - }); - } + res.json({ success: true, data: { teamId, require2fa: required } }); }); export default router; diff --git a/backend/src/routes/push-notifications.ts b/backend/src/routes/push-notifications.ts index c0bf05e..8125d47 100644 --- a/backend/src/routes/push-notifications.ts +++ b/backend/src/routes/push-notifications.ts @@ -1,210 +1,82 @@ import { Router, Response } from 'express'; +import { z } from 'zod'; import { supabase } from '../config/database'; import { authenticate, AuthenticatedRequest } from '../middleware/auth'; -import logger from '../config/logger'; +import { validateRequest } from '../utils/validation'; const router = Router(); - router.use(authenticate); +const subscribeSchema = z.object({ + endpoint: z.string().url(), + keys: z.object({ + p256dh: z.string().min(1), + auth: z.string().min(1), + }), + userAgent: z.string().optional(), +}); + /** - * @openapi - * /api/notifications/push/subscribe: - * post: - * tags: [Push Notifications] - * summary: Save a browser push subscription - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [endpoint, keys] - * properties: - * endpoint: { type: string, format: uri } - * keys: - * type: object - * required: [p256dh, auth] - * properties: - * p256dh: { type: string } - * auth: { type: string } - * userAgent: { type: string } - * responses: - * 201: - * description: Subscription saved - * content: - * application/json: - * schema: - * type: object - * properties: - * success: { type: boolean } - * data: - * type: object - * properties: - * id: { type: string } - * endpoint: { type: string } - * createdAt: { type: string, format: date-time } - * 400: - * description: Missing or invalid fields - * 401: - * description: Unauthorized + * POST /api/notifications/push/subscribe */ router.post('/subscribe', async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.id; - if (!userId) { - return res.status(401).json({ success: false, error: 'Unauthorized' }); - } - - const { endpoint, keys, userAgent } = req.body as { - endpoint?: string; - keys?: { p256dh?: string; auth?: string }; - userAgent?: string; - }; - - if (!endpoint || typeof endpoint !== 'string') { - return res.status(400).json({ success: false, error: 'Missing or invalid endpoint' }); - } - if (!keys?.p256dh || typeof keys.p256dh !== 'string') { - return res.status(400).json({ success: false, error: 'Missing or invalid p256dh key' }); - } - if (!keys?.auth || typeof keys.auth !== 'string') { - return res.status(400).json({ success: false, error: 'Missing or invalid auth key' }); - } - - const { data, error } = await supabase - .from('push_subscriptions') - .upsert( - { - user_id: userId, - endpoint, - p256dh: keys.p256dh, - auth: keys.auth, - user_agent: userAgent ?? null, - updated_at: new Date().toISOString(), - }, - { onConflict: 'user_id,endpoint' }, - ) - .select('id, endpoint, created_at') - .single(); - - if (error) { - logger.error('Failed to save push subscription:', error); - return res.status(500).json({ success: false, error: 'Failed to save subscription' }); - } - - logger.info('Push subscription saved', { userId, subscriptionId: data.id }); - - return res.status(201).json({ - success: true, - data: { id: data.id, endpoint: data.endpoint, createdAt: data.created_at }, - }); - } catch (err) { - logger.error('Push subscribe error:', err); - return res.status(500).json({ success: false, error: 'Internal server error' }); - } + const { endpoint, keys, userAgent } = validateRequest(subscribeSchema, req.body); + const userId = req.user!.id; + + const { data, error } = await supabase + .from('push_subscriptions') + .upsert( + { + user_id: userId, + endpoint, + p256dh: keys.p256dh, + auth: keys.auth, + user_agent: userAgent ?? null, + updated_at: new Date().toISOString(), + }, + { onConflict: 'user_id,endpoint' }, + ) + .select('id, endpoint, created_at') + .single(); + + if (error) throw error; + + res.status(201).json({ + success: true, + data: { id: data.id, endpoint: data.endpoint, createdAt: data.created_at }, + }); }); /** - * @openapi - * /api/notifications/push/unsubscribe: - * delete: - * tags: [Push Notifications] - * summary: Remove a push subscription - * security: - * - bearerAuth: [] - * requestBody: - * content: - * application/json: - * schema: - * type: object - * properties: - * endpoint: { type: string, description: "Omit to remove all subscriptions" } - * responses: - * 200: - * description: Removed - * 401: - * description: Unauthorized + * DELETE /api/notifications/push/unsubscribe */ router.delete('/unsubscribe', async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.id; - if (!userId) { - return res.status(401).json({ success: false, error: 'Unauthorized' }); - } - - const { endpoint } = req.body as { endpoint?: string }; - - let query = supabase.from('push_subscriptions').delete().eq('user_id', userId); - - if (endpoint && typeof endpoint === 'string') { - query = query.eq('endpoint', endpoint); - } + const { endpoint } = req.body as { endpoint?: string }; + const userId = req.user!.id; - const { error } = await query; + let query = supabase.from('push_subscriptions').delete().eq('user_id', userId); + if (endpoint && typeof endpoint === 'string') { + query = query.eq('endpoint', endpoint); + } - if (error) { - logger.error('Failed to remove push subscription:', error); - return res.status(500).json({ success: false, error: 'Failed to remove subscription' }); - } + const { error } = await query; + if (error) throw error; - logger.info('Push subscription(s) removed', { userId }); - return res.json({ success: true }); - } catch (err) { - logger.error('Push unsubscribe error:', err); - return res.status(500).json({ success: false, error: 'Internal server error' }); - } + res.json({ success: true }); }); /** - * @openapi - * /api/notifications/push/status: - * get: - * tags: [Push Notifications] - * summary: Check if user has an active push subscription - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: Subscription status - * content: - * application/json: - * schema: - * type: object - * properties: - * success: { type: boolean } - * data: - * type: object - * properties: - * subscribed: { type: boolean } - * count: { type: integer } - * 401: - * description: Unauthorized + * GET /api/notifications/push/status */ router.get('/status', async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.id; - if (!userId) { - return res.status(401).json({ success: false, error: 'Unauthorized' }); - } + const { count, error } = await supabase + .from('push_subscriptions') + .select('id', { count: 'exact', head: true }) + .eq('user_id', req.user!.id); - const { count, error } = await supabase - .from('push_subscriptions') - .select('id', { count: 'exact', head: true }) - .eq('user_id', userId); + if (error) throw error; - if (error) { - logger.error('Failed to check push subscription status:', error); - return res.status(500).json({ success: false, error: 'Failed to check status' }); - } - - return res.json({ success: true, data: { subscribed: (count ?? 0) > 0, count: count ?? 0 } }); - } catch (err) { - logger.error('Push status error:', err); - return res.status(500).json({ success: false, error: 'Internal server error' }); - } + res.json({ success: true, data: { subscribed: (count ?? 0) > 0, count: count ?? 0 } }); }); export default router; \ No newline at end of file diff --git a/backend/src/routes/risk-score.ts b/backend/src/routes/risk-score.ts index 4b9a9b1..1c0a84d 100644 --- a/backend/src/routes/risk-score.ts +++ b/backend/src/routes/risk-score.ts @@ -1,270 +1,88 @@ -/** - * Risk Score API Routes - */ - import express, { Response } from 'express'; +import { z } from 'zod'; import { riskDetectionService } from '../services/risk-detection/risk-detection-service'; -import { riskNotificationService } from '../services/risk-detection/risk-notification-service'; import { authenticate, AuthenticatedRequest } from '../middleware/auth'; -import logger from '../config/logger'; +import { validateRequest } from '../utils/validation'; +import { NotFoundError } from '../errors'; const router = express.Router(); - -// Apply authentication to all routes router.use(authenticate); +const subscriptionParamSchema = z.object({ + subscriptionId: z.string().uuid(), +}); + /** - * @openapi - * /api/risk-score/{subscriptionId}: - * get: - * tags: [Risk Score] - * summary: Get risk score for a subscription - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: subscriptionId - * required: true - * schema: { type: string, format: uuid } - * responses: - * 200: - * description: Risk score data - * content: - * application/json: - * schema: - * type: object - * properties: - * success: { type: boolean } - * data: { $ref: '#/components/schemas/RiskScore' } - * 401: - * description: Unauthorized - * 404: - * description: Risk score not found + * GET /api/risk-score/:subscriptionId */ router.get('/:subscriptionId', async (req: AuthenticatedRequest, res: Response) => { - try { - const { subscriptionId } = req.params; - const userId = req.user?.id; - - if (!userId) { - return res.status(401).json({ - success: false, - error: 'Unauthorized', - }); - } - - // Verify subscription belongs to user and get risk score - const riskScore = await riskDetectionService.getRiskScore( - Array.isArray(subscriptionId) ? subscriptionId[0] : subscriptionId, - userId - ); - - return res.status(200).json({ - success: true, - data: { - subscription_id: riskScore.subscription_id, - risk_level: riskScore.risk_level, - risk_factors: riskScore.risk_factors, - last_calculated_at: riskScore.last_calculated_at, - }, - }); - } catch (error) { - logger.error('Error fetching risk score:', error); - - if (error instanceof Error && error.message.includes('not found')) { - return res.status(404).json({ - success: false, - error: 'Risk score not found', - }); - } - - return res.status(500).json({ - success: false, - error: 'Internal server error', - }); - } + const { subscriptionId } = validateRequest(subscriptionParamSchema, req.params); + const userId = req.user!.id; + + const riskScore = await riskDetectionService.getRiskScore(subscriptionId, userId); + + res.json({ + success: true, + data: { + subscription_id: riskScore.subscription_id, + risk_level: riskScore.risk_level, + risk_factors: riskScore.risk_factors, + last_calculated_at: riskScore.last_calculated_at, + }, + }); }); /** - * @openapi - * /api/risk-score: - * get: - * tags: [Risk Score] - * summary: Get all risk scores for the authenticated user - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: Array of risk scores - * content: - * application/json: - * schema: - * type: object - * properties: - * success: { type: boolean } - * data: - * type: array - * items: { $ref: '#/components/schemas/RiskScore' } - * total: { type: integer } - * 401: - * description: Unauthorized + * GET /api/risk-score */ router.get('/', async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.id; - - if (!userId) { - return res.status(401).json({ - success: false, - error: 'Unauthorized', - }); - } - - const riskScores = await riskDetectionService.getUserRiskScores(userId); - - return res.status(200).json({ - success: true, - data: riskScores.map(score => ({ - subscription_id: score.subscription_id, - risk_level: score.risk_level, - risk_factors: score.risk_factors, - last_calculated_at: score.last_calculated_at, - })), - total: riskScores.length, - }); - } catch (error) { - logger.error('Error fetching user risk scores:', error); - - return res.status(500).json({ - success: false, - error: 'Internal server error', - }); - } + const userId = req.user!.id; + const riskScores = await riskDetectionService.getUserRiskScores(userId); + + res.json({ + success: true, + data: riskScores.map(score => ({ + subscription_id: score.subscription_id, + risk_level: score.risk_level, + risk_factors: score.risk_factors, + last_calculated_at: score.last_calculated_at, + })), + total: riskScores.length, + }); }); /** - * @openapi - * /api/risk-score/recalculate: - * post: - * tags: [Risk Score] - * summary: Trigger risk recalculation for all subscriptions - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: Recalculation result - * 401: - * description: Unauthorized + * POST /api/risk-score/recalculate */ router.post('/recalculate', async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.id; - - if (!userId) { - return res.status(401).json({ - success: false, - error: 'Unauthorized', - }); - } - - // TODO: Add admin check - // For now, allow any authenticated user to trigger recalculation - - logger.info('Manual risk recalculation triggered', { user_id: userId }); - - const result = await riskDetectionService.recalculateAllRisks(); - - return res.status(200).json({ - success: true, - data: result, - }); - } catch (error) { - logger.error('Error in manual risk recalculation:', error); + // TODO: Add admin check (e.g., req.user.role === 'admin') + const result = await riskDetectionService.recalculateAllRisks(); - return res.status(500).json({ - success: false, - error: 'Internal server error', - }); - } + res.json({ + success: true, + data: result, + }); }); /** - * @openapi - * /api/risk-score/{subscriptionId}/calculate: - * post: - * tags: [Risk Score] - * summary: Calculate risk for a specific subscription - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: subscriptionId - * required: true - * schema: { type: string, format: uuid } - * responses: - * 200: - * description: Calculated risk score - * content: - * application/json: - * schema: - * type: object - * properties: - * success: { type: boolean } - * data: { $ref: '#/components/schemas/RiskScore' } - * 401: - * description: Unauthorized - * 404: - * description: Subscription not found + * POST /api/risk-score/:subscriptionId/calculate */ router.post('/:subscriptionId/calculate', async (req: AuthenticatedRequest, res: Response) => { - try { - const { subscriptionId } = req.params; - const userId = req.user?.id; - - if (!userId) { - return res.status(401).json({ - success: false, - error: 'Unauthorized', - }); - } - - // Compute risk - const assessment = await riskDetectionService.computeRiskLevel( - Array.isArray(subscriptionId) ? subscriptionId[0] : subscriptionId - ); - - // Save risk score - const riskScore = await riskDetectionService.saveRiskScore(assessment, userId); - - // Trigger notification if needed - // Note: We need subscription details for notification - // For now, we'll skip notification in this endpoint - // In production, fetch subscription details and call notification service - - return res.status(200).json({ - success: true, - data: { - subscription_id: riskScore.subscription_id, - risk_level: riskScore.risk_level, - risk_factors: riskScore.risk_factors, - last_calculated_at: riskScore.last_calculated_at, - }, - }); - } catch (error) { - logger.error('Error calculating risk score:', error); - - if (error instanceof Error && error.message.includes('not found')) { - return res.status(404).json({ - success: false, - error: 'Subscription not found', - }); - } - - return res.status(500).json({ - success: false, - error: 'Internal server error', - }); - } + const { subscriptionId } = validateRequest(subscriptionParamSchema, req.params); + const userId = req.user!.id; + + const assessment = await riskDetectionService.computeRiskLevel(subscriptionId); + const riskScore = await riskDetectionService.saveRiskScore(assessment, userId); + + res.json({ + success: true, + data: { + subscription_id: riskScore.subscription_id, + risk_level: riskScore.risk_level, + risk_factors: riskScore.risk_factors, + last_calculated_at: riskScore.last_calculated_at, + }, + }); }); export default router; diff --git a/backend/src/routes/simulation.ts b/backend/src/routes/simulation.ts index e8a78a4..725404d 100644 --- a/backend/src/routes/simulation.ts +++ b/backend/src/routes/simulation.ts @@ -1,116 +1,33 @@ import { Router, Response } from 'express'; +import { z } from 'zod'; import { simulationService } from '../services/simulation-service'; import { authenticate, AuthenticatedRequest } from '../middleware/auth'; -import logger from '../config/logger'; +import { validateRequest } from '../utils/validation'; const router = Router(); - -// All routes require authentication router.use(authenticate); +const simulationQuerySchema = z.object({ + days: z.preprocess((val) => parseInt(val as string, 10), z.number().int().min(1).max(365)).default(30), + balance: z.preprocess((val) => val === undefined ? undefined : parseFloat(val as string), z.number().optional()), +}); + /** - * @openapi - * /api/simulation: - * get: - * tags: [Simulation] - * summary: Generate a billing simulation - * description: Projects upcoming billing charges for the authenticated user. - * security: - * - bearerAuth: [] - * parameters: - * - in: query - * name: days - * schema: { type: integer, minimum: 1, maximum: 365, default: 30 } - * description: Number of days to project - * - in: query - * name: balance - * schema: { type: number } - * description: Current balance for risk assessment - * responses: - * 200: - * description: Simulation result - * content: - * application/json: - * schema: - * type: object - * properties: - * success: { type: boolean } - * data: { type: object } - * 400: - * description: Invalid query parameters - * 401: - * description: Unauthorized + * GET /api/simulation */ router.get('/', async (req: AuthenticatedRequest, res: Response) => { - try { - // Parse and validate query parameters - const daysParam = req.query.days as string | undefined; - const balanceParam = req.query.balance as string | undefined; - - let days = 30; // Default value - if (daysParam) { - const parsedDays = parseInt(daysParam, 10); - - if (isNaN(parsedDays)) { - return res.status(400).json({ - success: false, - error: 'Days parameter must be a valid number', - }); - } - - if (parsedDays < 1 || parsedDays > 365) { - return res.status(400).json({ - success: false, - error: 'Days parameter must be between 1 and 365', - }); - } - - days = parsedDays; - } - - // Parse balance if provided - let balance: number | undefined; - if (balanceParam) { - const parsedBalance = parseFloat(balanceParam); - - if (isNaN(parsedBalance)) { - return res.status(400).json({ - success: false, - error: 'Balance parameter must be a valid number', - }); - } - - balance = parsedBalance; - } - - // Generate simulation - const result = await simulationService.generateSimulation( - req.user!.id, - days, - balance - ); - - res.json({ - success: true, - data: result, - }); - } catch (error) { - logger.error('Simulation generation error:', error); - - // Handle validation errors - if (error instanceof Error && error.message.includes('must be between')) { - return res.status(400).json({ - success: false, - error: error.message, - }); - } - - // Handle other errors - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to generate simulation', - }); - } + const { days, balance } = validateRequest(simulationQuerySchema, req.query); + + const result = await simulationService.generateSimulation( + req.user!.id, + days, + balance + ); + + res.json({ + success: true, + data: result, + }); }); export default router; diff --git a/backend/src/routes/subscriptions.ts b/backend/src/routes/subscriptions.ts index b8e3038..da190ba 100644 --- a/backend/src/routes/subscriptions.ts +++ b/backend/src/routes/subscriptions.ts @@ -1,33 +1,21 @@ -import { Router, Response } from "express"; -import { subscriptionService } from "../services/subscription-service"; -import { idempotencyService } from "../services/idempotency"; -import { authenticate, AuthenticatedRequest } from "../middleware/auth"; -import { - validateSubscriptionOwnership, - validateBulkSubscriptionOwnership, -} from "../middleware/ownership"; -import logger from "../config/logger"; import { Router, Response } from 'express'; +import { z } from 'zol'; // Wait, it's 'zod' import { z } from 'zod'; +import multer from 'multer'; import { subscriptionService } from '../services/subscription-service'; -import { giftCardService } from '../services/gift-card-service'; import { idempotencyService } from '../services/idempotency'; +import { giftCardService } from '../services/gift-card-service'; +import { notificationPreferenceService } from '../services/notification-preference-service'; import { authenticate, AuthenticatedRequest } from '../middleware/auth'; import { validateSubscriptionOwnership, validateBulkSubscriptionOwnership } from '../middleware/ownership'; +import { SUPPORTED_CURRENCIES } from '../constants/currencies'; import logger from '../config/logger'; -import type { Subscription } from '../types/subscription'; +import { ValidationError, NotFoundError, BadRequestError, ConflictError } from '../errors'; +import { validateRequest } from '../utils/validation'; -const resolveParam = (p: string | string[]): string => - Array.isArray(p) ? p[0] : p; +const router = Router(); -// Zod schema for URL fields — only http/https allowed -import multer from 'multer'; -import { notificationPreferenceService } from '../services/notification-preference-service'; -import { requireRole } from '../middleware/rbac'; -import { auditService } from '../services/audit-service'; -import { previewImport, commitImport, CSV_TEMPLATE } from '../services/csv-import-service'; -import { SUPPORTED_CURRENCIES } from '../constants/currencies'; -import { authenticate, AuthenticatedRequest, requireScope } from '../middleware/auth'; +// Configure multer for CSV imports const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 1 * 1024 * 1024 }, // 1 MB @@ -39,8 +27,9 @@ const upload = multer({ } }, }); + // ── Zod schemas ─────────────────────────────────────────────────────────────── -// URL fields — only http/https allowed + const safeUrlSchema = z .string() .url('Must be a valid URL') @@ -56,11 +45,9 @@ const safeUrlSchema = z { message: 'URL must use http or https protocol' } ); -// Validation schema for subscription create input - { message: 'URL must use http or https protocol' }, const createSubscriptionSchema = z.object({ name: z.string().min(1), - price: z.number(), + price: z.number().min(0), billing_cycle: z.enum(['monthly', 'yearly', 'quarterly']), currency: z.string() .refine( @@ -71,14 +58,10 @@ const createSubscriptionSchema = z.object({ renewal_url: safeUrlSchema.optional(), website_url: safeUrlSchema.optional(), logo_url: safeUrlSchema.optional(), + category: z.string().optional(), }); -// Validation schema for subscription update input -const updateSubscriptionSchema = z.object({ - renewal_url: safeUrlSchema.optional(), - website_url: safeUrlSchema.optional(), - logo_url: safeUrlSchema.optional(), -}).passthrough(); +const updateSubscriptionSchema = createSubscriptionSchema.partial().passthrough(); const notificationPreferencesSchema = z.object({ reminder_days_before: z @@ -99,1909 +82,368 @@ const snoozeSchema = z.object({ until: z.string().datetime({ offset: true }), }); -// ── Router ──────────────────────────────────────────────────────────────────── - -const router = Router(); - -// All routes require authentication -router.use(authenticate); -import * as bip39 from 'bip39'; - -/** - * @openapi - * /api/subscriptions: - * get: - * tags: [Subscriptions] - * summary: List subscriptions - * description: Returns all subscriptions for the authenticated user with optional filtering. - * security: - * - bearerAuth: [] - * parameters: - * - in: query - * name: status - * schema: { type: string, enum: [active, cancelled, expired] } - * - in: query - * name: category - * schema: { type: string } - * - in: query - * name: limit - * schema: { type: integer, default: 20 } - * - in: query - * name: offset - * schema: { type: integer, default: 0 } - * responses: - * 200: - * description: List of subscriptions - * content: - * application/json: - * schema: - * type: object - * properties: - * success: { type: boolean } - * data: - * type: array - * items: { $ref: '#/components/schemas/Subscription' } - * pagination: { $ref: '#/components/schemas/Pagination' } - * 401: - * description: Unauthorized - * content: - * application/json: - * schema: { $ref: '#/components/schemas/ErrorResponse' } - */ -router.get("/", async (req: AuthenticatedRequest, res: Response) => { - try { - const { status, category, limit, offset } = req.query; +const pauseSchema = z.object({ + resumeAt: z.string().datetime({ offset: true }).optional(), + reason: z.string().max(500).optional(), +}); - const result = await subscriptionService.listSubscriptions(req.user!.id, { - status: status as string | undefined, - category: category as string | undefined, - limit: limit ? parseInt(limit as string) : undefined, - offset: offset ? parseInt(offset as string) : undefined, - * GET /api/subscriptions - * List user's subscriptions with cursor-based pagination and optional filtering. - * - * Query params: - * limit - max items per page (1–100, default 20) - * cursor - opaque base64 cursor returned by previous response - * status - filter by subscription status - * category - filter by category - * - * Response pagination object: - * total - total count across all pages (ignores cursor / limit) - * limit - effective page size used - * hasMore - whether another page exists after this one - * nextCursor - cursor to pass on the next request (null when on last page) - const rawLimit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined; - // Reject non-numeric or out-of-range limit values early - if (rawLimit !== undefined && (isNaN(rawLimit) || rawLimit < 1)) { - return res.status(400).json({ - success: false, - error: "limit must be a positive integer", - }); - } - status: req.query.status as Subscription['status'] | undefined, - category: req.query.category as string | undefined, - limit: rawLimit, - cursor: req.query.cursor as string | undefined, -router.get('/', async (req: AuthenticatedRequest, res: Response) => { - const { status, category, limit, offset } = req.query as Record; -router.get('/', requireScope('subscriptions:read'), async (req: AuthenticatedRequest, res: Response) => { - const allowedStatuses = new Set(['active','expired','cancelled','paused','trial']); - const normalizedStatus = - typeof status === 'string' && allowedStatuses.has(status) ? (status as any) : undefined; - const normalizedCategory = typeof category === 'string' ? category : undefined; - const lim = typeof limit === 'string' ? parseInt(limit) : undefined; - const off = typeof offset === 'string' ? parseInt(offset) : undefined; +const bulkOperationSchema = z.object({ + operation: z.enum(['delete', 'update']), + ids: z.array(z.string().uuid()), + data: z.any().optional(), +}); - const result = await subscriptionService.listSubscriptions(req.user!.id, { - status: normalizedStatus, - category: normalizedCategory, - limit: lim, - offset: off, - }); +// ── Helpers ─────────────────────────────────────────────────────────────────── - res.json({ - success: true, - data: result.subscriptions, - pagination: { - total: result.total, - limit: limit ? parseInt(limit as string) : undefined, - offset: offset ? parseInt(offset as string) : undefined, - limit: Math.min(rawLimit ?? 20, 100), - hasMore: result.hasMore, - nextCursor: result.nextCursor ?? null, - }, - }); - } catch (error) { - logger.error("List subscriptions error:", error); +function extractWaitTime(message: string): number { + const match = message.match(/wait (\d+) seconds/); + return match ? parseInt(match[1], 10) : 60; +} - // Surface cursor decode errors as 400 rather than 500 - if (error instanceof Error && error.message.includes("cursor")) { - return res.status(400).json({ - success: false, - error: error.message, - }); - } +// ── Router ──────────────────────────────────────────────────────────────────── - res.status(500).json({ - success: false, - error: - error instanceof Error ? error.message : "Failed to list subscriptions", - pagination: { total: result.total, limit: lim, offset: off }, - logger.error('List subscriptions error:', error); - error: error instanceof Error ? error.message : 'Failed to list subscriptions', - }); - } -}); +// All routes require authentication +router.use(authenticate); /** - * GET /api/subscriptions/:id - * Get single subscription by ID - * @openapi - * /api/subscriptions/{id}: - * get: - * tags: [Subscriptions] - * summary: Get a subscription - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: { type: string, format: uuid } - * responses: - * 200: - * description: Subscription object - * content: - * application/json: - * schema: - * type: object - * properties: - * success: { type: boolean } - * data: { $ref: '#/components/schemas/Subscription' } - * 401: - * description: Unauthorized - * 404: - * description: Not found + * GET /api/subscriptions + * List user's subscriptions */ -router.get("/:id", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - try { - const subscription = await subscriptionService.getSubscription( - req.user!.id, - Array.isArray(req.params.id) ? req.params.id[0] : req.params.id - resolveParam(req.params.id) - ); - - res.json({ - success: true, - data: subscription, - }); - } catch (error) { - logger.error("Get subscription error:", error); - const statusCode = - error instanceof Error && error.message.includes("not found") - ? 404 - : 500; - res.status(statusCode).json({ - success: false, - error: - error instanceof Error ? error.message : "Failed to get subscription", - }); +router.get('/', async (req: AuthenticatedRequest, res: Response) => { + const { status, category, limit, cursor } = req.query; + + const limitNum = limit ? parseInt(limit as string, 10) : 20; + if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) { + throw new BadRequestError('Limit must be a number between 1 and 100'); } -}); - -/** - * GET /api/subscriptions/:id/price-history - * Get price history for a subscription - */ -router.get("/:id/price-history", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - try { - const history = await subscriptionService.getPriceHistory( - req.user!.id, - Array.isArray(req.params.id) ? req.params.id[0] : req.params.id - ); - res.json({ - success: true, - data: history, - }); - } catch (error) { - logger.error("Get price history error:", error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : "Failed to get price history", -router.get('/:id', validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - Array.isArray(req.params.id) ? req.params.id[0] : req.params.id, - res.json({ success: true, data: subscription }); - logger.error('Get subscription error:', error); - error instanceof Error && error.message.includes('not found') ? 404 : 500; -router.get('/:id', requireScope('subscriptions:read'), validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - error: error instanceof Error ? error.message : 'Failed to get subscription', - }); - } + const result = await subscriptionService.listSubscriptions(req.user!.id, { + status: status as any, + category: category as string, + limit: limitNum, + cursor: cursor as string, + }); + + res.json({ + success: true, + data: result.subscriptions, + pagination: { + total: result.total, + limit: limitNum, + hasMore: result.hasMore, + nextCursor: result.nextCursor ?? null, + }, + }); }); /** * POST /api/subscriptions * Create new subscription with idempotency support - * @openapi - * /api/subscriptions: - * post: - * tags: [Subscriptions] - * summary: Create a subscription - * security: - * - bearerAuth: [] - * parameters: - * - in: header - * name: Idempotency-Key - * schema: { type: string } - * description: Optional key to prevent duplicate submissions - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [name, price, billing_cycle] - * properties: - * name: { type: string, example: Netflix } - * price: { type: number, example: 15.99 } - * billing_cycle: { type: string, enum: [monthly, yearly, quarterly] } - * renewal_url: { type: string, format: uri } - * website_url: { type: string, format: uri } - * logo_url: { type: string, format: uri } - * responses: - * 201: - * description: Subscription created - * content: - * application/json: - * schema: - * type: object - * properties: - * success: { type: boolean } - * data: { $ref: '#/components/schemas/Subscription' } - * blockchain: { $ref: '#/components/schemas/BlockchainResult' } - * 207: - * description: Created but blockchain sync failed - * 400: - * description: Validation error - * 401: - * description: Unauthorized */ -router.post("/", async (req: AuthenticatedRequest, res: Response) => { - try { - const idempotencyKey = req.headers["idempotency-key"] as string; - const requestHash = idempotencyService.hashRequest(req.body); - - // Check idempotency if key provided router.post('/', async (req: AuthenticatedRequest, res: Response) => { - const idempotencyKey = req.headers['idempotency-key'] as string; -router.post('/', requireScope('subscriptions:write'), async (req: AuthenticatedRequest, res: Response) => { - if (idempotencyKey) { - const idempotencyCheck = await idempotencyService.checkIdempotency( - idempotencyKey, - req.user!.id, - requestHash, - ); - - if (idempotencyCheck.isDuplicate && idempotencyCheck.cachedResponse) { - logger.info("Returning cached response for idempotent request", { - idempotencyKey, - userId: req.user!.id, - }); - - logger.info('Returning cached response for idempotent request', { - return res - .status(idempotencyCheck.cachedResponse.status) - .json(idempotencyCheck.cachedResponse.body); - } - } - - // Validate input - const { name, price, billing_cycle } = req.body; - if (!name || price === undefined || !billing_cycle) { - return res.status(400).json({ - success: false, - error: "Missing required fields: name, price, billing_cycle", - }); - } - - // Validate URL fields - error: 'Missing required fields: name, price, billing_cycle', - const urlValidation = createSubscriptionSchema.safeParse(req.body); - if (!urlValidation.success) { - return res.status(400).json({ - success: false, - error: urlValidation.error.errors.map((e) => e.message).join(', '), - }); - } - - // Create subscription - const result = await subscriptionService.createSubscription( - req.user!.id, - req.body, - idempotencyKey || undefined - idempotencyKey || undefined, - ); - - const responseBody = { - success: true, - data: result.subscription, - blockchain: { - synced: result.syncStatus === "synced", - synced: result.syncStatus === 'synced', - transactionHash: result.blockchainResult?.transactionHash, - error: result.blockchainResult?.error, - }, - }; - - const statusCode = result.syncStatus === "failed" ? 207 : 201; - - // Store idempotency record if key provided - const statusCode = result.syncStatus === 'failed' ? 207 : 201; - if (idempotencyKey) { - await idempotencyService.storeResponse( - idempotencyKey, - req.user!.id, - requestHash, - statusCode, - responseBody, - ); - } + const idempotencyKey = req.headers['idempotency-key'] as string; + const validatedData = validateRequest(createSubscriptionSchema, req.body); + + const result = await subscriptionService.createSubscription( + req.user!.id, + validatedData, + idempotencyKey + ); + + const statusCode = result.syncStatus === 'failed' ? 207 : 201; + res.status(statusCode).json({ + success: true, + data: result.subscription, + blockchain: { + synced: result.syncStatus === 'synced', + transactionHash: result.blockchainResult?.transactionHash, + error: result.blockchainResult?.error, + }, + }); +}); - res.status(statusCode).json(responseBody); - } catch (error) { - logger.error("Create subscription error:", error); - res.status(500).json({ - success: false, - error: - error instanceof Error - ? error.message - : "Failed to create subscription", - logger.error('Create subscription error:', error); - error: error instanceof Error ? error.message : 'Failed to create subscription', - }); - } +/** + * GET /api/subscriptions/:id + * Get single subscription + */ +router.get('/:id', validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { + const subscription = await subscriptionService.getSubscription(req.user!.id, req.params.id); + res.json({ success: true, data: subscription }); }); /** * PATCH /api/subscriptions/:id * Update subscription with optimistic locking - * @openapi - * /api/subscriptions/{id}: - * patch: - * tags: [Subscriptions] - * summary: Update a subscription - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: { type: string, format: uuid } - * - in: header - * name: Idempotency-Key - * schema: { type: string } - * - in: header - * name: If-Match - * schema: { type: string } - * description: Expected version for optimistic locking - * requestBody: - * content: - * application/json: - * schema: - * type: object - * properties: - * name: { type: string } - * price: { type: number } - * billing_cycle: { type: string, enum: [monthly, yearly, quarterly] } - * renewal_url: { type: string, format: uri } - * website_url: { type: string, format: uri } - * logo_url: { type: string, format: uri } - * responses: - * 200: - * description: Updated subscription - * content: - * application/json: - * schema: - * type: object - * properties: - * success: { type: boolean } - * data: { $ref: '#/components/schemas/Subscription' } - * blockchain: { $ref: '#/components/schemas/BlockchainResult' } - * 207: - * description: Updated but blockchain sync failed - * 401: - * description: Unauthorized - * 404: - * description: Not found */ -router.patch("/:id", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - try { - const idempotencyKey = req.headers["idempotency-key"] as string; - const requestHash = idempotencyService.hashRequest(req.body); - - // Check idempotency if key provided router.patch('/:id', validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - const idempotencyKey = req.headers['idempotency-key'] as string; -router.patch('/:id', requireScope('subscriptions:write'), validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - if (idempotencyKey) { - const idempotencyCheck = await idempotencyService.checkIdempotency( - idempotencyKey, - req.user!.id, - requestHash, - ); - - if (idempotencyCheck.isDuplicate && idempotencyCheck.cachedResponse) { - return res - .status(idempotencyCheck.cachedResponse.status) - .json(idempotencyCheck.cachedResponse.body); - } - } - - const expectedVersion = req.headers["if-match"] as string; - - // Validate URL fields - const expectedVersion = req.headers['if-match'] as string; - const urlValidation = updateSubscriptionSchema.safeParse(req.body); - if (!urlValidation.success) { - return res.status(400).json({ - success: false, - error: urlValidation.error.errors.map((e) => e.message).join(', '), - }); - } - - const result = await subscriptionService.updateSubscription( - req.user!.id, - Array.isArray(req.params.id) ? req.params.id[0] : req.params.id, - resolveParam(req.params.id), - req.body, - expectedVersion ? parseInt(expectedVersion) : undefined, - ); - - const responseBody = { - success: true, - data: result.subscription, - blockchain: { - synced: result.syncStatus === "synced", - synced: result.syncStatus === 'synced', - transactionHash: result.blockchainResult?.transactionHash, - error: result.blockchainResult?.error, - }, - }; - - const statusCode = result.syncStatus === "failed" ? 207 : 200; - - // Store idempotency record if key provided - const statusCode = result.syncStatus === 'failed' ? 207 : 200; - if (idempotencyKey) { - await idempotencyService.storeResponse( - idempotencyKey, - req.user!.id, - requestHash, - statusCode, - responseBody, - ); - } - - res.status(statusCode).json(responseBody); - } catch (error) { - logger.error("Update subscription error:", error); - const statusCode = - error instanceof Error && error.message.includes("not found") - ? 404 - : 500; - res.status(statusCode).json({ - success: false, - error: - error instanceof Error ? error.message : "Failed to update subscription", - logger.error('Update subscription error:', error); - error instanceof Error && error.message.includes('not found') ? 404 : 500; - error: error instanceof Error ? error.message : 'Failed to update subscription', - }); - } + const expectedVersion = req.headers['if-match'] as string; + const validatedData = validateRequest(updateSubscriptionSchema, req.body); + + const result = await subscriptionService.updateSubscription( + req.user!.id, + req.params.id, + validatedData, + expectedVersion ? parseInt(expectedVersion, 10) : undefined + ); + + const statusCode = result.syncStatus === 'failed' ? 207 : 200; + res.status(statusCode).json({ + success: true, + data: result.subscription, + blockchain: { + synced: result.syncStatus === 'synced', + transactionHash: result.blockchainResult?.transactionHash, + error: result.blockchainResult?.error, + }, + }); }); /** * DELETE /api/subscriptions/:id * Delete subscription - * @openapi - * /api/subscriptions/{id}: - * delete: - * tags: [Subscriptions] - * summary: Delete a subscription - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: { type: string, format: uuid } - * responses: - * 200: - * description: Deleted - * 207: - * description: Deleted but blockchain sync failed - * 401: - * description: Unauthorized - * 404: - * description: Not found */ -router.delete("/:id", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - try { - const result = await subscriptionService.deleteSubscription( - const result = await subscriptionService.cancelSubscription( - req.user!.id, - Array.isArray(req.params.id) ? req.params.id[0] : req.params.id - resolveParam(req.params.id) -router.delete("/:id", validateSubscriptionOwnership, requireRole('owner', 'admin'), async (req: AuthenticatedRequest, res: Response) => { -router.delete('/:id', requireScope('subscriptions:write'), validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - Array.isArray(req.params.id) ? req.params.id[0] : req.params.id, - ); - - const responseBody = { - success: true, - message: "Subscription deleted", - blockchain: { - synced: result.syncStatus === "synced", - message: 'Subscription deleted', - synced: result.syncStatus === 'synced', - transactionHash: result.blockchainResult?.transactionHash, - error: result.blockchainResult?.error, - }, - }; - - const statusCode = result.syncStatus === "failed" ? 207 : 200; +router.delete('/:id', validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { + const result = await subscriptionService.deleteSubscription(req.user!.id, req.params.id); + + const statusCode = result.syncStatus === 'failed' ? 207 : 200; + res.status(statusCode).json({ + success: true, + message: 'Subscription deleted', + blockchain: { + synced: result.syncStatus === 'synced', + transactionHash: result.blockchainResult?.transactionHash, + error: result.blockchainResult?.error, + }, + }); +}); - res.status(statusCode).json(responseBody); - } catch (error) { - logger.error("Delete subscription error:", error); - const statusCode = - error instanceof Error && error.message.includes("not found") - ? 404 - : 500; - res.status(statusCode).json({ - success: false, - error: - error instanceof Error - ? error.message - : "Failed to delete subscription", - const statusCode = result.syncStatus === 'failed' ? 207 : 200; - logger.error('Delete subscription error:', error); - error instanceof Error && error.message.includes('not found') ? 404 : 500; - error: error instanceof Error ? error.message : 'Failed to delete subscription', - }); - } +/** + * GET /api/subscriptions/:id/price-history + */ +router.get('/:id/price-history', validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { + const history = await subscriptionService.getPriceHistory(req.user!.id, req.params.id); + res.json({ success: true, data: history }); }); /** * POST /api/subscriptions/:id/attach-gift-card - * Attach gift card info to a subscription - * @openapi - * /api/subscriptions/{id}/attach-gift-card: - * post: - * tags: [Subscriptions] - * summary: Attach a gift card to a subscription - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: { type: string, format: uuid } - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [giftCardHash, provider] - * properties: - * giftCardHash: { type: string } - * provider: { type: string } - * responses: - * 201: - * description: Gift card attached - * 400: - * description: Validation error - * 401: - * description: Unauthorized - * 404: - * description: Subscription not found */ router.post('/:id/attach-gift-card', validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - try { - const subscriptionId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - const subscriptionId = resolveParam(req.params.id); - if (!subscriptionId) { - return res.status(400).json({ success: false, error: 'Subscription ID required' }); - } - const { giftCardHash, provider } = req.body; - - if (!giftCardHash || !provider) { - return res.status(400).json({ - success: false, - error: 'Missing required fields: giftCardHash, provider', - }); - } - - const result = await giftCardService.attachGiftCard( - req.user!.id, - subscriptionId, - giftCardHash, - provider - ); + const { giftCardHash, provider } = req.body; + if (!giftCardHash || !provider) { + throw new BadRequestError('Missing required fields: giftCardHash, provider'); + } - if (!result.success) { - const statusCode = result.error?.includes('not found') || result.error?.includes('access denied') ? 404 : 400; - return res.status(statusCode).json({ - success: false, - error: result.error, - }); - provider, - const statusCode = - result.error?.includes('not found') || result.error?.includes('access denied') ? 404 : 400; - return res.status(statusCode).json({ success: false, error: result.error }); - } + const result = await giftCardService.attachGiftCard( + req.user!.id, + req.params.id, + giftCardHash, + provider + ); - res.status(201).json({ - success: true, - data: result.data, - blockchain: { - transactionHash: result.blockchainResult?.transactionHash, - error: result.blockchainResult?.error, - }, - }); - } catch (error) { - logger.error('Attach gift card error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to attach gift card', - }); + if (!result.success) { + throw new BadRequestError(result.error || 'Failed to attach gift card'); } + + res.status(201).json({ + success: true, + data: result.data, + blockchain: { + transactionHash: result.blockchainResult?.transactionHash, + error: result.blockchainResult?.error, + }, + }); }); /** * POST /api/subscriptions/:id/retry-sync - * Retry blockchain sync for a subscription - * Enforces cooldown period to prevent rapid repeated attempts - * @openapi - * /api/subscriptions/{id}/retry-sync: - * post: - * tags: [Subscriptions] - * summary: Retry blockchain sync for a subscription - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: { type: string, format: uuid } - * responses: - * 200: - * description: Sync result - * 401: - * description: Unauthorized - * 429: - * description: Cooldown period active - * content: - * application/json: - * schema: - * type: object - * properties: - * success: { type: boolean } - * error: { type: string } - * retryAfter: { type: integer, description: Seconds to wait } */ -router.post("/:id/retry-sync", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - try { - const result = await subscriptionService.retryBlockchainSync( - req.user!.id, - Array.isArray(req.params.id) ? req.params.id[0] : req.params.id - resolveParam(req.params.id) - * Retry blockchain sync — enforces cooldown period router.post('/:id/retry-sync', validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - Array.isArray(req.params.id) ? req.params.id[0] : req.params.id, - ); - + try { + const result = await subscriptionService.retryBlockchainSync(req.user!.id, req.params.id); res.json({ success: result.success, transactionHash: result.transactionHash, error: result.error, }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Failed to retry sync"; - - // Check if it's a cooldown error - if (errorMessage.includes("Cooldown period active")) { - logger.warn("Retry sync rejected due to cooldown:", errorMessage); - const errorMessage = error instanceof Error ? error.message : 'Failed to retry sync'; - - if (errorMessage.includes('Cooldown period active')) { - logger.warn('Retry sync rejected due to cooldown:', errorMessage); - return res.status(429).json({ + } catch (error: any) { + if (error.message?.includes('Cooldown period active')) { + res.status(429).json({ success: false, - error: errorMessage, - retryAfter: extractWaitTime(errorMessage), + error: error.message, + retryAfter: extractWaitTime(error.message), }); + return; } - - logger.error("Retry sync error:", error); - res.status(500).json({ - success: false, - error: errorMessage, - }); - - logger.error('Retry sync error:', error); - res.status(500).json({ success: false, error: errorMessage }); + throw error; } }); /** * GET /api/subscriptions/:id/cooldown-status - * Check if a subscription can be retried or if cooldown is active - * @openapi - * /api/subscriptions/{id}/cooldown-status: - * get: - * tags: [Subscriptions] - * summary: Check retry cooldown status - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: { type: string, format: uuid } - * responses: - * 200: - * description: Cooldown status - * content: - * application/json: - * schema: - * type: object - * properties: - * success: { type: boolean } - * canRetry: { type: boolean } - * isOnCooldown: { type: boolean } - * timeRemainingSeconds: { type: integer, nullable: true } - * message: { type: string } - * 401: - * description: Unauthorized */ -router.get("/:id/cooldown-status", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - try { - const cooldownStatus = await subscriptionService.checkRenewalCooldown( - req.params.id, - Array.isArray(req.params.id) ? req.params.id[0] : req.params.id, - resolveParam(req.params.id), - * Check cooldown status for a subscription router.get('/:id/cooldown-status', validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - const cooldownStatus = await subscriptionService.checkRenewalCooldown(req.params.id); - Array.isArray(req.params.id) ? req.params.id[0] : req.params.id, - ); - - res.json({ - success: true, - canRetry: cooldownStatus.canRetry, - isOnCooldown: cooldownStatus.isOnCooldown, - timeRemainingSeconds: cooldownStatus.timeRemainingSeconds, - message: cooldownStatus.message, - }); - } catch (error) { - logger.error("Cooldown status check error:", error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : "Failed to check cooldown status", - logger.error('Cooldown status check error:', error); - error: error instanceof Error ? error.message : 'Failed to check cooldown status', - }); - } + const cooldownStatus = await subscriptionService.checkRenewalCooldown(req.params.id); + res.json({ success: true, ...cooldownStatus }); }); -// Helper function to extract wait time from error message -function extractWaitTime(message: string): number { - const match = message.match(/wait (\d+) seconds/); - return match ? parseInt(match[1], 10) : 60; -import * as bip39 from 'bip39'; - * Generates a standard BIP39 12-word mnemonic phrase. /** * POST /api/subscriptions/:id/cancel - * Cancel subscription with blockchain sync + * Stop billing but keep record */ router.post('/:id/cancel', validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - try { - const idempotencyKey = req.headers['idempotency-key'] as string; - const requestHash = idempotencyService.hashRequest(req.body); - - if (idempotencyKey) { - const idempotencyCheck = await idempotencyService.checkIdempotency( - idempotencyKey, - req.user!.id, - requestHash, - ); - - if (idempotencyCheck.isDuplicate && idempotencyCheck.cachedResponse) { - return res - .status(idempotencyCheck.cachedResponse.status) - .json(idempotencyCheck.cachedResponse.body); - } - } - - const result = await subscriptionService.cancelSubscription( - req.user!.id, - Array.isArray(req.params.id) ? req.params.id[0] : req.params.id, - ); - - const responseBody = { - success: true, - data: result.subscription, - blockchain: { - synced: result.syncStatus === 'synced', - transactionHash: result.blockchainResult?.transactionHash, - error: result.blockchainResult?.error, - }, - }; - - const statusCode = result.syncStatus === 'failed' ? 207 : 200; - - if (idempotencyKey) { - await idempotencyService.storeResponse( - idempotencyKey, - req.user!.id, - requestHash, - statusCode, - responseBody, - ); - } - - res.status(statusCode).json(responseBody); - } catch (error) { - logger.error('Cancel subscription error:', error); - const statusCode = - error instanceof Error && error.message.includes('not found') ? 404 : 500; - res.status(statusCode).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to cancel subscription', - }); - } + const result = await subscriptionService.cancelSubscription(req.user!.id, req.params.id); + + const statusCode = result.syncStatus === 'failed' ? 207 : 200; + res.status(statusCode).json({ + success: true, + data: result.subscription, + blockchain: { + synced: result.syncStatus === 'synced', + transactionHash: result.blockchainResult?.transactionHash, + error: result.blockchainResult?.error, + }, + }); }); /** * POST /api/subscriptions/:id/pause - * Pause subscription — skips reminders, risk scoring, and projected spend - * Body: { resumeAt?: string (ISO date), reason?: string } */ -/** - * POST /api/subscriptions/:id/pause - * Pause subscription — skips reminders, risk scoring, and projected spend - * Body: { resumeAt?: string (ISO date), reason?: string } - */ -router.post("/:id/pause", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - try { - const idempotencyKey = req.headers["idempotency-key"] as string; - const requestHash = idempotencyService.hashRequest(req.body); - - if (idempotencyKey) { - const idempotencyCheck = await idempotencyService.checkIdempotency( - idempotencyKey, - req.user!.id, - requestHash, - ); - - if (idempotencyCheck.isDuplicate && idempotencyCheck.cachedResponse) { - return res - .status(idempotencyCheck.cachedResponse.status) - .json(idempotencyCheck.cachedResponse.body); - } - } - - const pauseSchema = z.object({ - resumeAt: z.string().datetime({ offset: true }).optional(), - reason: z.string().max(500).optional(), - }); - - const validation = pauseSchema.safeParse(req.body); - if (!validation.success) { - return res.status(400).json({ - success: false, - error: validation.error.errors.map((e) => e.message).join(", "), - }); - } - - const { resumeAt, reason } = validation.data; - - if (resumeAt && new Date(resumeAt) <= new Date()) { - return res.status(400).json({ - success: false, - error: "resumeAt must be a future date", - }); - } - - const result = await subscriptionService.pauseSubscription( - req.user!.id, - Array.isArray(req.params.id) ? req.params.id[0] : req.params.id, - resumeAt, - reason, - ); - - const responseBody = { - success: true, - data: result.subscription, - blockchain: { - synced: result.syncStatus === "synced", - transactionHash: result.blockchainResult?.transactionHash, - error: result.blockchainResult?.error, - }, - }; - - const statusCode = result.syncStatus === "failed" ? 207 : 200; +router.post('/:id/pause', validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { + const { resumeAt, reason } = validateRequest(pauseSchema, req.body); + + if (resumeAt && new Date(resumeAt) <= new Date()) { + throw new BadRequestError('resumeAt must be a future date'); + } - if (idempotencyKey) { - await idempotencyService.storeResponse( - idempotencyKey, - req.user!.id, - requestHash, - statusCode, - responseBody, - ); - } + const result = await subscriptionService.pauseSubscription( + req.user!.id, + req.params.id, + resumeAt, + reason + ); - res.status(statusCode).json(responseBody); - } catch (error) { - logger.error("Pause subscription error:", error); - const statusCode = - error instanceof Error && error.message.includes("not found") ? 404 - : error instanceof Error && error.message.includes("already paused") ? 409 - : 500; - res.status(statusCode).json({ - success: false, - error: error instanceof Error ? error.message : "Failed to pause subscription", - }); - } + const statusCode = result.syncStatus === 'failed' ? 207 : 200; + res.status(statusCode).json({ + success: true, + data: result.subscription, + blockchain: { + synced: result.syncStatus === 'synced', + transactionHash: result.blockchainResult?.transactionHash, + error: result.blockchainResult?.error, + }, + }); }); /** * POST /api/subscriptions/:id/resume - * Resume a paused subscription — re-enables reminders and risk scoring */ -router.post("/:id/resume", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - try { - const idempotencyKey = req.headers["idempotency-key"] as string; - const requestHash = idempotencyService.hashRequest(req.body); - - if (idempotencyKey) { - const idempotencyCheck = await idempotencyService.checkIdempotency( - idempotencyKey, - req.user!.id, - requestHash, - ); - - if (idempotencyCheck.isDuplicate && idempotencyCheck.cachedResponse) { - return res - .status(idempotencyCheck.cachedResponse.status) - .json(idempotencyCheck.cachedResponse.body); - } - } - - const result = await subscriptionService.resumeSubscription( - req.user!.id, - Array.isArray(req.params.id) ? req.params.id[0] : req.params.id, - ); - - const responseBody = { - success: true, - data: result.subscription, - blockchain: { - synced: result.syncStatus === "synced", - transactionHash: result.blockchainResult?.transactionHash, - error: result.blockchainResult?.error, - }, - }; - - const statusCode = result.syncStatus === "failed" ? 207 : 200; - - if (idempotencyKey) { - await idempotencyService.storeResponse( - idempotencyKey, - req.user!.id, - requestHash, - statusCode, - responseBody, - ); - } - - res.status(statusCode).json(responseBody); - } catch (error) { - logger.error("Resume subscription error:", error); - const statusCode = - error instanceof Error && error.message.includes("not found") ? 404 - : error instanceof Error && error.message.includes("not paused") ? 409 - : 500; - res.status(statusCode).json({ - success: false, - error: error instanceof Error ? error.message : "Failed to resume subscription", - }); - } +router.post('/:id/resume', validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { + const result = await subscriptionService.resumeSubscription(req.user!.id, req.params.id); + + const statusCode = result.syncStatus === 'failed' ? 207 : 200; + res.status(statusCode).json({ + success: true, + data: result.subscription, + blockchain: { + synced: result.syncStatus === 'synced', + transactionHash: result.blockchainResult?.transactionHash, + error: result.blockchainResult?.error, + }, + }); }); /** * POST /api/subscriptions/bulk - * Bulk operations (delete, update status, etc.) */ -router.post("/bulk", validateBulkSubscriptionOwnership, requireRole('owner', 'admin'), async (req: AuthenticatedRequest, res: Response) => { router.post('/bulk', validateBulkSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - try { - const { operation, ids, data } = req.body; - - if (!operation || !ids || !Array.isArray(ids)) { - return res.status(400).json({ - success: false, - error: 'Missing required fields: operation, ids', - }); - } - - const results = []; - const errors = []; + const { operation, ids, data } = validateRequest(bulkOperationSchema, req.body); + + const results = []; + const errors = []; - for (const id of ids) { - try { - let result; - switch (operation) { - case 'delete': - result = await subscriptionService.deleteSubscription(req.user!.id, id); - break; - case 'update': - if (!data) throw new Error('Update data required'); - result = await subscriptionService.updateSubscription(req.user!.id, id, data); - break; - default: - throw new Error(`Unknown operation: ${operation}`); - } - results.push({ id, success: true, result }); - } catch (error) { - errors.push({ id, error: error instanceof Error ? error.message : String(error) }); + for (const id of ids) { + try { + let result; + if (operation === 'delete') { + result = await subscriptionService.deleteSubscription(req.user!.id, id); + } else { + if (!data) throw new BadRequestError('Update data required'); + result = await subscriptionService.updateSubscription(req.user!.id, id, data); } + results.push({ id, success: true, result }); + } catch (error: any) { + errors.push({ id, error: error.message || String(error) }); } - - res.json({ - success: errors.length === 0, - results, - errors: errors.length > 0 ? errors : undefined, - }); - } catch (error) { - logger.error('Bulk operation error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to perform bulk operation', - }); } + + res.json({ + success: errors.length === 0, + results, + errors: errors.length > 0 ? errors : undefined, + }); }); /** * PATCH /api/subscriptions/:id/notification-preferences - * Create or update per-subscription notification preferences */ -router.patch( - '/:id/notification-preferences', - validateSubscriptionOwnership, - async (req: AuthenticatedRequest, res: Response) => { - try { - const validation = notificationPreferencesSchema.safeParse(req.body); - if (!validation.success) { - return res.status(400).json({ - success: false, - error: validation.error.errors.map((e) => e.message).join(', '), - }); - } - - const subscriptionId = Array.isArray(req.params.id) - ? req.params.id[0] - : req.params.id; - - const preferences = await notificationPreferenceService.upsertPreferences( - subscriptionId, - validation.data, - ); +router.patch('/:id/notification-preferences', validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { + const validatedData = validateRequest(notificationPreferencesSchema, req.body); + + const preferences = await notificationPreferenceService.upsertPreferences( + req.params.id, + validatedData + ); - res.json({ success: true, data: preferences }); - } catch (error) { - logger.error('Update notification preferences error:', error); - res.status(500).json({ - success: false, - error: - error instanceof Error - ? error.message - : 'Failed to update notification preferences', - }); - } - }, -); + res.json({ success: true, data: preferences }); +}); /** * POST /api/subscriptions/:id/snooze - * Mute reminders for a subscription until a specific date */ -router.post( - '/:id/snooze', - validateSubscriptionOwnership, - async (req: AuthenticatedRequest, res: Response) => { - try { - const validation = snoozeSchema.safeParse(req.body); - if (!validation.success) { - return res.status(400).json({ - success: false, - error: validation.error.errors.map((e) => e.message).join(', '), - }); - } - - const subscriptionId = Array.isArray(req.params.id) - ? req.params.id[0] - : req.params.id; - - const preferences = await notificationPreferenceService.snooze( - subscriptionId, - validation.data.until, - ); - - res.json({ - success: true, - data: preferences, - message: `Reminders snoozed until ${validation.data.until}`, - }); - } catch (error) { - logger.error('Snooze subscription error:', error); - - const isValidationError = - error instanceof Error && - (error.message.includes('Invalid snooze date') || - error.message.includes('must be in the future')); - - res.status(isValidationError ? 400 : 500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to snooze subscription', - }); - } - }, -); - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function extractWaitTime(message: string): number { - const match = message.match(/wait (\d+) seconds/); - return match ? parseInt(match[1], 10) : 60; -export function generateMnemonic(): string { - return bip39.generateMnemonic(128); -} +router.post('/:id/snooze', validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { + const { until } = validateRequest(snoozeSchema, req.body); + + const preferences = await notificationPreferenceService.snooze(req.params.id, until); + + res.json({ + success: true, + data: preferences, + message: `Reminders snoozed until ${until}`, + }); +}); /** - * POST /api/subscriptions/:id/trial/convert - * Mark a trial as intentionally converted to paid ("Keep My Subscription"). - * Logs the conversion event and updates the subscription status. + * Trial conversion routes */ router.post('/:id/trial/convert', validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - try { - const subId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - - const { data: sub, error: fetchErr } = await (await import('../config/database')).supabase - .from('subscriptions') - .select('*') - .eq('id', subId) - .eq('user_id', req.user!.id) - .single(); - - if (fetchErr || !sub) { - return res.status(404).json({ success: false, error: 'Subscription not found' }); - } - - if (!sub.is_trial) { - return res.status(400).json({ success: false, error: 'Subscription is not a trial' }); - } - - const db = (await import('../config/database')).supabase; - - // Update subscription: mark as active paid subscription - await db.from('subscriptions').update({ - is_trial: false, - status: 'active', - price: sub.trial_converts_to_price ?? sub.price_after_trial ?? sub.price, - updated_at: new Date().toISOString(), - }).eq('id', subId); - - // Log conversion event - await db.from('trial_conversion_events').insert({ - subscription_id: subId, - user_id: req.user!.id, - outcome: 'converted', - conversion_type: 'intentional', - saved_by_syncro: false, - converted_price: sub.trial_converts_to_price ?? sub.price_after_trial ?? sub.price, - }); - - res.json({ success: true, message: 'Trial converted to paid subscription' }); - } catch (error) { - logger.error('Trial convert error:', error); - res.status(500).json({ success: false, error: error instanceof Error ? error.message : 'Failed to convert trial' }); - } + const result = await subscriptionService.convertTrial(req.user!.id, req.params.id); + res.json({ success: true, message: 'Trial converted successfully', data: result }); }); -/** - * POST /api/subscriptions/:id/trial/cancel - * Cancel a trial before auto-charge. Counts toward "Saved by SYNCRO" metric. - */ router.post('/:id/trial/cancel', validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - try { - const subId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - const { acted_on_reminder_days } = req.body; - - const db = (await import('../config/database')).supabase; - - const { data: sub, error: fetchErr } = await db - .from('subscriptions') - .select('*') - .eq('id', subId) - .eq('user_id', req.user!.id) - .single(); - - if (fetchErr || !sub) { - return res.status(404).json({ success: false, error: 'Subscription not found' }); - } - - if (!sub.is_trial) { - return res.status(400).json({ success: false, error: 'Subscription is not a trial' }); - } - - // Cancel the subscription - await db.from('subscriptions').update({ - status: 'cancelled', - cancelled_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }).eq('id', subId); - - // Log cancellation — saved_by_syncro = true when credit card was on file - await db.from('trial_conversion_events').insert({ - subscription_id: subId, - user_id: req.user!.id, - outcome: 'cancelled', - conversion_type: 'intentional', - saved_by_syncro: sub.credit_card_required === true, - acted_on_reminder_days: acted_on_reminder_days ?? null, - }); - - res.json({ success: true, message: 'Trial cancelled successfully' }); - } catch (error) { - logger.error('Trial cancel error:', error); - res.status(500).json({ success: false, error: error instanceof Error ? error.message : 'Failed to cancel trial' }); - } + const { acted_on_reminder_days } = req.body; + const result = await subscriptionService.cancelTrial(req.user!.id, req.params.id, acted_on_reminder_days); + res.json({ success: true, message: 'Trial cancelled successfully', data: result }); }); -/** - * GET /api/subscriptions/trials/saved-metric - * Returns the "Saved by SYNCRO" count — trials cancelled before auto-charge. - */ router.get('/trials/saved-metric', async (req: AuthenticatedRequest, res: Response) => { - try { - const db = (await import('../config/database')).supabase; - - const { count, error } = await db - .from('trial_conversion_events') - .select('*', { count: 'exact', head: true }) - .eq('user_id', req.user!.id) - .eq('saved_by_syncro', true); - - if (error) throw error; - - res.json({ success: true, savedCount: count ?? 0 }); - } catch (error) { - logger.error('Saved metric error:', error); - res.status(500).json({ success: false, error: 'Failed to fetch saved metric' }); - } + const count = await subscriptionService.getSavedTrialsCount(req.user!.id); + res.json({ success: true, savedCount: count }); }); -/** - * POST /api/subscriptions/:id/cancel - * Cancel subscription with blockchain sync - * @openapi - * /api/subscriptions/{id}/cancel: - * post: - * tags: [Subscriptions] - * summary: Cancel a subscription - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: { type: string, format: uuid } - * - in: header - * name: Idempotency-Key - * schema: { type: string } - * responses: - * 200: - * description: Cancelled - * 207: - * description: Cancelled but blockchain sync failed - * 401: - * description: Unauthorized - * 404: - * description: Not found - * Generates a standard BIP39 12-word mnemonic phrase. -export function generateMnemonic(): string { - return bip39.generateMnemonic(128); - * Validates a given mnemonic phrase (must be 12 words). - */ -router.get("/:id", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - try { - const subscription = await subscriptionService.getSubscription( - req.user!.id, - req.params.id, - ); - - res.json({ - success: true, - data: subscription, - }); - } catch (error) { - logger.error("Get subscription error:", error); -router.post("/:id/cancel", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - const idempotencyKey = req.headers["idempotency-key"] as string; - const requestHash = idempotencyService.hashRequest(req.body); - // Check idempotency if key provided - if (idempotencyKey) { - const idempotencyCheck = await idempotencyService.checkIdempotency( - idempotencyKey, - req.user!.id, - requestHash, - ); - if (idempotencyCheck.isDuplicate && idempotencyCheck.cachedResponse) { - return res - .status(idempotencyCheck.cachedResponse.status) - .json(idempotencyCheck.cachedResponse.body); - } - } - const result = await subscriptionService.cancelSubscription( - Array.isArray(req.params.id) ? req.params.id[0] : req.params.id, - const responseBody = { - - req.user!.id, - resolveParam(req.params.id), - ); - - success: true, - data: result.subscription, - blockchain: { - synced: result.syncStatus === "synced", - transactionHash: result.blockchainResult?.transactionHash, - error: result.blockchainResult?.error, - }, - }; - const statusCode = result.syncStatus === "failed" ? 207 : 200; - if (idempotencyKey) { - await idempotencyService.storeResponse( - idempotencyKey, - req.user!.id, - requestHash, - statusCode, - responseBody, - ); - } - res.status(statusCode).json(responseBody); - - } catch (error) { - logger.error("Cancel subscription error:", error); - const statusCode = - error instanceof Error && error.message.includes("not found") - ? 404 - : 500; - res.status(statusCode).json({ - success: false, - error: - error instanceof Error ? error.message : "Failed to get subscription", - error instanceof Error - ? error.message - : "Failed to cancel subscription", - }); -export function validateMnemonic(mnemonic: string): boolean { - if (!mnemonic || typeof mnemonic !== 'string') { - return false; - } - -/** - * POST /api/subscriptions - * Create new subscription with idempotency support - */ -router.post("/", async (req: AuthenticatedRequest, res: Response) => { - * POST /api/subscriptions/:id/pause - * Pause subscription — skips reminders, risk scoring, and projected spend - * Body: { resumeAt?: string (ISO date), reason?: string } -router.post("/:id/pause", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - try { - const idempotencyKey = req.headers["idempotency-key"] as string; - const requestHash = idempotencyService.hashRequest(req.body); - - // Check idempotency if key provided - if (idempotencyKey) { - const idempotencyCheck = await idempotencyService.checkIdempotency( - idempotencyKey, - req.user!.id, - requestHash, - ); - - if (idempotencyCheck.isDuplicate && idempotencyCheck.cachedResponse) { - logger.info("Returning cached response for idempotent request", { - idempotencyKey, - userId: req.user!.id, - }); - - return res - .status(idempotencyCheck.cachedResponse.status) - .json(idempotencyCheck.cachedResponse.body); - } - } - - // Validate input - const { name, price, billing_cycle } = req.body; - if (!name || price === undefined || !billing_cycle) { - return res.status(400).json({ - success: false, - error: "Missing required fields: name, price, billing_cycle", - }); - } - - // Create subscription - const result = await subscriptionService.createSubscription( - req.user!.id, - req.body, - idempotencyKey, - const pauseSchema = z.object({ - resumeAt: z.string().datetime({ offset: true }).optional(), - reason: z.string().max(500).optional(), - }); - const validation = pauseSchema.safeParse(req.body); - if (!validation.success) { - error: validation.error.errors.map((e) => e.message).join(", "), - const { resumeAt, reason } = validation.data; - if (resumeAt && new Date(resumeAt) <= new Date()) { - error: "resumeAt must be a future date", - const result = await subscriptionService.pauseSubscription( - resolveParam(req.params.id), - resumeAt, - reason, - ); - - const responseBody = { - success: true, - data: result.subscription, - blockchain: { - synced: result.syncStatus === "synced", - transactionHash: result.blockchainResult?.transactionHash, - error: result.blockchainResult?.error, - }, - }; - - const statusCode = result.syncStatus === "failed" ? 207 : 201; - - // Store idempotency record if key provided - const statusCode = result.syncStatus === "failed" ? 207 : 200; - if (idempotencyKey) { - await idempotencyService.storeResponse( - idempotencyKey, - req.user!.id, - requestHash, - statusCode, - responseBody, - ); - } - - res.status(statusCode).json(responseBody); - } catch (error) { - logger.error("Create subscription error:", error); - res.status(500).json({ - success: false, - error: - error instanceof Error - ? error.message - : "Failed to create subscription", - * POST /api/subscriptions/bulk - * Bulk operations (delete, update status, etc.) -router.post("/bulk", validateBulkSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - const { operation, ids, data } = req.body; - if (!operation || !ids || !Array.isArray(ids)) { - error: "Missing required fields: operation, ids", - const results = []; - const errors = []; - * @openapi - * /api/subscriptions/bulk: - * post: - * tags: [Subscriptions] - * summary: Bulk operations on subscriptions - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [operation, ids] - * properties: - * operation: { type: string, enum: [delete, update] } - * ids: - * type: array - * items: { type: string, format: uuid } - * data: - * type: object - * description: Required when operation is "update" - * responses: - * 200: - * description: Bulk operation results - * 400: - * description: Validation error - * 401: - * description: Unauthorized - for (const id of ids) { - try { - let result; - switch (operation) { - case "delete": - result = await subscriptionService.cancelSubscription(req.user!.id, id); - result = await subscriptionService.deleteSubscription(req.user!.id, id); - break; - case "update": - if (!data) throw new Error("Update data required"); - result = await subscriptionService.updateSubscription(req.user!.id, id, data); - break; - default: - throw new Error(`Unknown operation: ${operation}`); - } - results.push({ id, success: true, result }); - } catch (error) { - errors.push({ id, error: error instanceof Error ? error.message : String(error) }); - } - } - - res.json({ - success: errors.length === 0, - results, - errors: errors.length > 0 ? errors : undefined, - }); - logger.error("Bulk operation error:", error); - } catch (error) { - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : "Failed to perform bulk operation", - logger.error("Pause subscription error:", error); - const statusCode = - error instanceof Error && error.message.includes("not found") ? 404 - : error instanceof Error && error.message.includes("already paused") ? 409 - : 500; - res.status(statusCode).json({ - error: error instanceof Error ? error.message : "Failed to pause subscription", - }); - const words = mnemonic.trim().split(/\s+/); - if (words.length !== 12) { - return false; - } - -/** - * PATCH /api/subscriptions/:id - * Update subscription with optimistic locking - */ -router.patch("/:id", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - * POST /api/subscriptions/:id/resume - * Resume a paused subscription — re-enables reminders and risk scoring -router.post("/:id/resume", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - try { - const idempotencyKey = req.headers["idempotency-key"] as string; - const requestHash = idempotencyService.hashRequest(req.body); - - // Check idempotency if key provided - if (idempotencyKey) { - const idempotencyCheck = await idempotencyService.checkIdempotency( - idempotencyKey, - req.user!.id, - requestHash, - ); - - if (idempotencyCheck.isDuplicate && idempotencyCheck.cachedResponse) { - return res - .status(idempotencyCheck.cachedResponse.status) - .json(idempotencyCheck.cachedResponse.body); - } - } - - const expectedVersion = req.headers["if-match"] as string; - - const result = await subscriptionService.updateSubscription( - req.user!.id, - Array.isArray(req.params.id) ? req.params.id[0] : req.params.id, - req.body, - expectedVersion ? parseInt(expectedVersion) : undefined, - ); - - const responseBody = { - success: true, - data: result.subscription, - blockchain: { - synced: result.syncStatus === "synced", - transactionHash: result.blockchainResult?.transactionHash, - error: result.blockchainResult?.error, - }, - }; - - const statusCode = result.syncStatus === "failed" ? 207 : 200; - - // Store idempotency record if key provided - if (idempotencyKey) { - await idempotencyService.storeResponse( - idempotencyKey, - req.user!.id, - requestHash, - statusCode, - responseBody, - ); - } - - res.status(statusCode).json(responseBody); - } catch (error) { - logger.error("Update subscription error:", error); - const statusCode = - error instanceof Error && error.message.includes("not found") - ? 404 - : 500; - res.status(statusCode).json({ - success: false, - error: - error instanceof Error ? error.message : "Failed to update subscription", - }); - } -}); - -/** - * DELETE /api/subscriptions/:id - * Delete subscription - */ -router.delete("/:id", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - try { - const result = await subscriptionService.deleteSubscription( - req.user!.id, - req.params.id, - ); - - const responseBody = { - success: true, - message: "Subscription deleted", - blockchain: { - synced: result.syncStatus === "synced", - transactionHash: result.blockchainResult?.transactionHash, - error: result.blockchainResult?.error, - }, - }; - - const statusCode = result.syncStatus === "failed" ? 207 : 200; - - res.status(statusCode).json(responseBody); - } catch (error) { - logger.error("Delete subscription error:", error); - const statusCode = - error instanceof Error && error.message.includes("not found") - ? 404 - : 500; - res.status(statusCode).json({ - success: false, - error: - error instanceof Error - ? error.message - : "Failed to delete subscription", - }); - } -}); - -/** - * POST /api/subscriptions/:id/attach-gift-card - * Attach gift card info to a subscription - */ -router.post('/:id/attach-gift-card', validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - try { - const subscriptionId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - if (!subscriptionId) { - return res.status(400).json({ success: false, error: 'Subscription ID required' }); - } - const { giftCardHash, provider } = req.body; - - if (!giftCardHash || !provider) { - return res.status(400).json({ - success: false, - error: 'Missing required fields: giftCardHash, provider', - }); - } - - const result = await giftCardService.attachGiftCard( - req.user!.id, - subscriptionId, - giftCardHash, - provider - ); - - if (!result.success) { - const statusCode = result.error?.includes('not found') || result.error?.includes('access denied') ? 404 : 400; - return res.status(statusCode).json({ - success: false, - error: result.error, - }); - } - - res.status(201).json({ - success: true, - data: result.data, - blockchain: { - transactionHash: result.blockchainResult?.transactionHash, - error: result.blockchainResult?.error, - }, - }); - } catch (error) { - logger.error('Attach gift card error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to attach gift card', - }); - } +// CSV Import Routes +router.post('/import/preview', upload.single('file'), async (req: AuthenticatedRequest, res: Response) => { + if (!req.file) throw new BadRequestError('No file uploaded'); + const preview = await subscriptionService.previewImport(req.user!.id, req.file.buffer); + res.json({ success: true, data: preview }); }); -/** - * POST /api/subscriptions/:id/retry-sync - * Retry blockchain sync for a subscription - */ -router.post("/:id/retry-sync", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - try { - const result = await subscriptionService.retryBlockchainSync( - req.user!.id, - req.params.id, - ); - - res.json({ - success: result.success, - transactionHash: result.transactionHash, - error: result.error, - }); - } catch (error) { - logger.error("Retry sync error:", error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : "Failed to retry sync", - }); - } -}); - -/** - * POST /api/subscriptions/:id/cancel - * Cancel subscription with blockchain sync - */ -router.post("/:id/cancel", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - try { - const idempotencyKey = req.headers["idempotency-key"] as string; - const requestHash = idempotencyService.hashRequest(req.body); - - // Check idempotency if key provided - if (idempotencyKey) { - const idempotencyCheck = await idempotencyService.checkIdempotency( - idempotencyKey, - req.user!.id, - requestHash, - ); - - if (idempotencyCheck.isDuplicate && idempotencyCheck.cachedResponse) { - return res - .status(idempotencyCheck.cachedResponse.status) - .json(idempotencyCheck.cachedResponse.body); - } - } - - const result = await subscriptionService.cancelSubscription( - req.user!.id, - req.params.id, - const result = await subscriptionService.resumeSubscription( - resolveParam(req.params.id), - ); - - const responseBody = { - success: true, - data: result.subscription, - blockchain: { - synced: result.syncStatus === "synced", - transactionHash: result.blockchainResult?.transactionHash, - error: result.blockchainResult?.error, - }, - }; - - const statusCode = result.syncStatus === "failed" ? 207 : 200; - - if (idempotencyKey) { - await idempotencyService.storeResponse( - idempotencyKey, - req.user!.id, - requestHash, - statusCode, - responseBody, - ); - } - - res.status(statusCode).json(responseBody); - } catch (error) { - logger.error("Cancel subscription error:", error); - const statusCode = - error instanceof Error && error.message.includes("not found") - ? 404 - : 500; - res.status(statusCode).json({ - success: false, - error: - error instanceof Error - ? error.message - : "Failed to cancel subscription", - logger.error("Resume subscription error:", error); - error instanceof Error && error.message.includes("not found") ? 404 - : error instanceof Error && error.message.includes("not paused") ? 409 - : 500; - error: error instanceof Error ? error.message : "Failed to resume subscription", - }); - } -}); - -/** - * POST /api/subscriptions/bulk - * Bulk operations (delete, update status, etc.) - */ -router.post("/bulk", validateBulkSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { - try { - const { operation, ids, data } = req.body; - - if (!operation || !ids || !Array.isArray(ids)) { - return res.status(400).json({ - success: false, - error: "Missing required fields: operation, ids", - }); - } - - const results = []; - const errors = []; - - for (const id of ids) { - try { - let result; - switch (operation) { - case "delete": - result = await subscriptionService.deleteSubscription(req.user!.id, id); - break; - case "update": - if (!data) throw new Error("Update data required"); - result = await subscriptionService.updateSubscription(req.user!.id, id, data); - break; - default: - throw new Error(`Unknown operation: ${operation}`); - } - results.push({ id, success: true, result }); - } catch (error) { - errors.push({ id, error: error instanceof Error ? error.message : String(error) }); - } - } - - res.json({ - success: errors.length === 0, - results, - errors: errors.length > 0 ? errors : undefined, - }); - } catch (error) { - logger.error("Bulk operation error:", error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : "Failed to perform bulk operation", - }); - const words = mnemonic.trim().split(/\s+/); - if (words.length !== 12) { - return false; - } +router.post('/import/commit', async (req: AuthenticatedRequest, res: Response) => { + const { importId } = req.body; + if (!importId) throw new BadRequestError('Import ID required'); + const result = await subscriptionService.commitImport(req.user!.id, importId); + res.json({ success: true, data: result }); }); export default router; - return bip39.validateMnemonic(words.join(' ')); -} diff --git a/backend/src/routes/team.ts b/backend/src/routes/team.ts index 0fd6a8d..92a0e99 100644 --- a/backend/src/routes/team.ts +++ b/backend/src/routes/team.ts @@ -6,6 +6,11 @@ import { requireRole } from '../middleware/rbac'; import { emailService } from '../services/email-service'; import { createTeamInviteLimiter } from '../middleware/rate-limit-factory'; import logger from '../config/logger'; +import { validateRequest } from '../utils/validation'; +import { NotFoundError, ForbiddenError, ConflictError, BadRequestError } from '../errors'; + +const router = Router(); +router.use(authenticate); // ─── Validation schemas ─────────────────────────────────────────────────────── @@ -27,29 +32,18 @@ const updateRoleSchema = z.object({ }), }); - -const router = Router(); - -router.use(authenticate); - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- +// ─── Helpers ────────────────────────────────────────────────────────────────── /** * Find the team associated with a user (owned or member). - * Returns { teamId, isOwner, memberRole } or null if no team. */ -async function resolveUserTeam( - userId: string -): Promise<{ teamId: string; isOwner: boolean; memberRole: string | null } | null> { - // Check ownership first +async function resolveUserTeam(userId: string) { + // Check ownership const { data: ownedTeam } = await supabase .from('teams') .select('id') .eq('owner_id', userId) - .limit(1) - .single(); + .maybeSingle(); if (ownedTeam) { return { teamId: ownedTeam.id, isOwner: true, memberRole: null }; @@ -60,8 +54,7 @@ async function resolveUserTeam( .from('team_members') .select('team_id, role') .eq('user_id', userId) - .limit(1) - .single(); + .maybeSingle(); if (membership) { return { teamId: membership.team_id, isOwner: false, memberRole: membership.role }; @@ -70,544 +63,295 @@ async function resolveUserTeam( return null; } -/** - * Return true if the user can perform admin-level team actions (invite / remove). - */ function canManageTeam(ctx: { isOwner: boolean; memberRole: string | null }): boolean { return ctx.isOwner || ctx.memberRole === 'admin'; } -// --------------------------------------------------------------------------- -// GET /api/team — list team members -// --------------------------------------------------------------------------- +// ─── Routes ─────────────────────────────────────────────────────────────────── + /** - * @openapi - * /api/team: - * get: - * tags: [Team] - * summary: List team members - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: Array of team members - * content: - * application/json: - * schema: - * type: object - * properties: - * success: { type: boolean } - * data: - * type: array - * items: { $ref: '#/components/schemas/TeamMember' } - * 401: - * description: Unauthorized + * GET /api/team + * List team members */ router.get('/', async (req: AuthenticatedRequest, res: Response) => { - try { - const ctx = await resolveUserTeam(req.user!.id); - - if (!ctx) { - return res.json({ success: true, data: [] }); - } - - // Fetch members with basic user profile from auth.users via supabase admin - const { data: members, error } = await supabase - .from('team_members') - .select('id, user_id, role, joined_at') - .eq('team_id', ctx.teamId) - .order('joined_at', { ascending: true }); - - if (error) throw error; - - // Enrich each member with their email from auth.users - const enriched = await Promise.all( - (members ?? []).map(async (m) => { - const { data: userData } = await supabase.auth.admin.getUserById(m.user_id); - return { - id: m.id, - userId: m.user_id, - email: userData?.user?.email ?? null, - role: m.role, - joinedAt: m.joined_at, - }; - }) - ); - - res.json({ success: true, data: enriched }); - } catch (error) { - logger.error('GET /api/team error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to list team members', - }); + const ctx = await resolveUserTeam(req.user!.id); + if (!ctx) { + return res.json({ success: true, data: [] }); } + + const { data: members, error } = await supabase + .from('team_members') + .select('id, user_id, role, joined_at') + .eq('team_id', ctx.teamId) + .order('joined_at', { ascending: true }); + + if (error) throw error; + + const enriched = await Promise.all( + (members ?? []).map(async (m) => { + const { data: userData } = await supabase.auth.admin.getUserById(m.user_id); + return { + id: m.id, + userId: m.user_id, + email: userData?.user?.email ?? null, + role: m.role, + joinedAt: m.joined_at, + }; + }) + ); + + res.json({ success: true, data: enriched }); }); -// --------------------------------------------------------------------------- -// POST /api/team/invite — invite a new member -// --------------------------------------------------------------------------- /** - * @openapi - * /api/team/invite: - * post: - * tags: [Team] - * summary: Invite a team member - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [email] - * properties: - * email: { type: string, format: email } - * role: { type: string, enum: [admin, member, viewer], default: member } - * responses: - * 201: - * description: Invitation sent - * 400: - * description: Validation error - * 401: - * description: Unauthorized - * 403: - * description: Forbidden — only owners/admins can invite - * 409: - * description: Pending invitation already exists or user already a member + * POST /api/team/invite */ -router.post('/invite', async (req: AuthenticatedRequest, res: Response) => { router.post('/invite', createTeamInviteLimiter(), async (req: AuthenticatedRequest, res: Response) => { -router.post('/invite', createTeamInviteLimiter(), requireRole('owner', 'admin'), async (req: AuthenticatedRequest, res: Response) => { - try { - const bodyValidation = inviteSchema.safeParse(req.body); - if (!bodyValidation.success) { - return res.status(400).json({ - success: false, - error: bodyValidation.error.errors.map((e) => e.message).join(', '), - }); - } + const { email, role } = validateRequest(inviteSchema, req.body); - const { email, role } = bodyValidation.data; + let ctx = await resolveUserTeam(req.user!.id); - // Ensure user has (or creates) a team - let ctx = await resolveUserTeam(req.user!.id); - - if (!ctx) { - // Auto-create a team for first-time owners - const { data: newTeam, error: createErr } = await supabase - .from('teams') - .insert({ name: `${req.user!.email}'s Team`, owner_id: req.user!.id }) - .select('id') - .single(); - - if (createErr || !newTeam) throw createErr ?? new Error('Failed to create team'); - ctx = { teamId: newTeam.id, isOwner: true, memberRole: null }; - } + if (!ctx) { + // Auto-create a team for first-time owners + const { data: newTeam, error: createErr } = await supabase + .from('teams') + .insert({ name: `${req.user!.email}'s Team`, owner_id: req.user!.id }) + .select('id') + .single(); - if (!canManageTeam(ctx)) { - return res.status(403).json({ success: false, error: 'Only team owners and admins can invite members' }); - } + if (createErr || !newTeam) throw createErr || new Error('Failed to create team'); + ctx = { teamId: newTeam.id, isOwner: true, memberRole: null }; + } - // Check for an existing active invitation for this email + team - const { data: existing } = await supabase - .from('team_invitations') - .select('id, expires_at') - .eq('team_id', ctx.teamId) - .eq('email', email) - .is('accepted_at', null) - .gt('expires_at', new Date().toISOString()) - .limit(1) - .single(); + if (!canManageTeam(ctx)) { + throw new ForbiddenError('Only team owners and admins can invite members'); + } - if (existing) { - return res.status(409).json({ success: false, error: 'A pending invitation already exists for this email' }); - } + // Check for an existing active invitation + const { data: existing } = await supabase + .from('team_invitations') + .select('id') + .eq('team_id', ctx.teamId) + .eq('email', email) + .is('accepted_at', null) + .gt('expires_at', new Date().toISOString()) + .maybeSingle(); + + if (existing) { + throw new ConflictError('A pending invitation already exists for this email'); + } - // Check if already a member + // Check if already a member + // Note: This requires admin lookup which might be restricted or slow + const { data: userLookup } = await (supabase.auth.admin as any).getUserByEmail?.(email) || { data: { user: null } }; + if (userLookup?.user) { const { data: alreadyMember } = await supabase .from('team_members') .select('id') .eq('team_id', ctx.teamId) - .eq('user_id', (await (supabase.auth.admin as any)?.getUserByEmail?.(email))?.data?.user?.id ?? '') - .limit(1) - .single(); + .eq('user_id', userLookup.user.id) + .maybeSingle(); if (alreadyMember) { - return res.status(409).json({ success: false, error: 'This user is already a team member' }); + throw new ConflictError('This user is already a team member'); } + } - const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days - - const { data: invitation, error: invErr } = await supabase - .from('team_invitations') - .insert({ - team_id: ctx.teamId, - email, - role, - invited_by: req.user!.id, - expires_at: expiresAt.toISOString(), - }) - .select('id, token, expires_at') - .single(); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + + const { data: invitation, error: invErr } = await supabase + .from('team_invitations') + .insert({ + team_id: ctx.teamId, + email, + role, + invited_by: req.user!.id, + expires_at: expiresAt.toISOString(), + }) + .select('id, token, expires_at') + .single(); - if (invErr || !invitation) throw invErr ?? new Error('Failed to create invitation'); + if (invErr || !invitation) throw invErr || new Error('Failed to create invitation'); - // Fetch team name for the email - const { data: team } = await supabase - .from('teams') - .select('name') - .eq('id', ctx.teamId) - .single(); + const { data: team } = await supabase + .from('teams') + .select('name') + .eq('id', ctx.teamId) + .single(); - const acceptUrl = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/team/accept/${invitation.token}`; - - // Fire-and-forget — don't block the response on email delivery - emailService - .sendInvitationEmail(email, { - inviterEmail: req.user!.email, - teamName: team?.name ?? 'your team', - role, - acceptUrl, - expiresAt, - }) - .catch((err) => logger.error('Invitation email failed:', err)); - - res.status(201).json({ - success: true, - data: { - id: invitation.id, - email, - role, - expiresAt: invitation.expires_at, - acceptUrl, - }, - }); - } catch (error) { - logger.error('POST /api/team/invite error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to send invitation', - }); - } + const acceptUrl = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/team/accept/${invitation.token}`; + + emailService + .sendInvitationEmail(email, { + inviterEmail: req.user!.email, + teamName: team?.name ?? 'your team', + role, + acceptUrl, + expiresAt, + }) + .catch((err) => logger.error('Invitation email failed:', err)); + + res.status(201).json({ + success: true, + data: { + id: invitation.id, + email, + role, + expiresAt: invitation.expires_at, + acceptUrl, + }, + }); }); -// --------------------------------------------------------------------------- -// GET /api/team/pending — list pending invitations -// --------------------------------------------------------------------------- /** - * @openapi - * /api/team/pending: - * get: - * tags: [Team] - * summary: List pending invitations - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: Pending invitations - * 401: - * description: Unauthorized - * 403: - * description: Forbidden + * GET /api/team/pending */ router.get('/pending', async (req: AuthenticatedRequest, res: Response) => { -router.get('/pending', requireRole('owner', 'admin'), async (req: AuthenticatedRequest, res: Response) => { - try { - const ctx = await resolveUserTeam(req.user!.id); + const ctx = await resolveUserTeam(req.user!.id); + if (!ctx || !canManageTeam(ctx)) { + throw new ForbiddenError('Only team owners and admins can view pending invitations'); + } - if (!ctx) { - return res.json({ success: true, data: [] }); - } + const { data: invitations, error } = await supabase + .from('team_invitations') + .select('id, email, role, expires_at, created_at, invited_by') + .eq('team_id', ctx.teamId) + .is('accepted_at', null) + .gt('expires_at', new Date().toISOString()) + .order('created_at', { ascending: false }); - if (!canManageTeam(ctx)) { - return res.status(403).json({ success: false, error: 'Only team owners and admins can view pending invitations' }); - } + if (error) throw error; - const { data: invitations, error } = await supabase - .from('team_invitations') - .select('id, email, role, expires_at, created_at, invited_by') - .eq('team_id', ctx.teamId) - .is('accepted_at', null) - .gt('expires_at', new Date().toISOString()) - .order('created_at', { ascending: false }); - - if (error) throw error; - - res.json({ success: true, data: invitations ?? [] }); - } catch (error) { - logger.error('GET /api/team/pending error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to list pending invitations', - }); - } + res.json({ success: true, data: invitations ?? [] }); }); -// --------------------------------------------------------------------------- -// POST /api/team/accept/:token — accept an invitation -// --------------------------------------------------------------------------- /** - * @openapi - * /api/team/accept/{token}: - * post: - * tags: [Team] - * summary: Accept a team invitation - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: token - * required: true - * schema: { type: string } - * responses: - * 200: - * description: Joined team - * 403: - * description: Email mismatch - * 404: - * description: Invitation not found or already used - * 410: - * description: Invitation expired + * POST /api/team/accept/:token */ router.post('/accept/:token', async (req: AuthenticatedRequest, res: Response) => { - try { - const { token } = req.params; - - const { data: invitation, error: fetchErr } = await supabase - .from('team_invitations') - .select('*') - .eq('token', token) - .is('accepted_at', null) - .single(); + const { token } = req.params; - if (fetchErr || !invitation) { - return res.status(404).json({ success: false, error: 'Invitation not found or already used' }); - } - - if (new Date(invitation.expires_at) < new Date()) { - return res.status(410).json({ success: false, error: 'Invitation has expired' }); - } - - // The authenticated user must match the invited email - if (req.user!.email !== invitation.email) { - return res.status(403).json({ - success: false, - error: 'This invitation was sent to a different email address', - }); - } - - // Check they're not already a member - const { data: existing } = await supabase - .from('team_members') - .select('id') - .eq('team_id', invitation.team_id) - .eq('user_id', req.user!.id) - .single(); + const { data: invitation, error: fetchErr } = await supabase + .from('team_invitations') + .select('*') + .eq('token', token) + .is('accepted_at', null) + .maybeSingle(); - if (existing) { - // Mark invitation accepted anyway and return success - await supabase - .from('team_invitations') - .update({ accepted_at: new Date().toISOString() }) - .eq('id', invitation.id); + if (fetchErr || !invitation) { + throw new NotFoundError('Invitation not found or already used'); + } - return res.json({ success: true, message: 'You are already a member of this team' }); - } + if (new Date(invitation.expires_at) < new Date()) { + throw new BadRequestError('Invitation has expired'); // Or perhaps a custom 410 if desired, but 400/404 is cleaner + } - // Add to team_members and mark invitation accepted in one go - const { error: memberErr } = await supabase - .from('team_members') - .insert({ team_id: invitation.team_id, user_id: req.user!.id, role: invitation.role }); + if (req.user!.email !== invitation.email) { + throw new ForbiddenError('This invitation was sent to a different email address'); + } - if (memberErr) throw memberErr; + const { data: existing } = await supabase + .from('team_members') + .select('id') + .eq('team_id', invitation.team_id) + .eq('user_id', req.user!.id) + .maybeSingle(); + if (existing) { await supabase .from('team_invitations') .update({ accepted_at: new Date().toISOString() }) .eq('id', invitation.id); - res.json({ success: true, message: 'You have joined the team', data: { role: invitation.role } }); - } catch (error) { - logger.error('POST /api/team/accept/:token error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to accept invitation', - }); + return res.json({ success: true, message: 'You are already a member of this team' }); } + + const { error: memberErr } = await supabase + .from('team_members') + .insert({ team_id: invitation.team_id, user_id: req.user!.id, role: invitation.role }); + + if (memberErr) throw memberErr; + + await supabase + .from('team_invitations') + .update({ accepted_at: new Date().toISOString() }) + .eq('id', invitation.id); + + res.json({ success: true, message: 'You have joined the team', data: { role: invitation.role } }); }); -// --------------------------------------------------------------------------- -// PUT /api/team/:memberId/role — update a member's role (owner only) -// --------------------------------------------------------------------------- /** - * @openapi - * /api/team/{memberId}/role: - * put: - * tags: [Team] - * summary: Update a member's role (owner only) - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: memberId - * required: true - * schema: { type: string, format: uuid } - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [role] - * properties: - * role: { type: string, enum: [admin, member, viewer] } - * responses: - * 200: - * description: Role updated - * 400: - * description: Invalid role - * 401: - * description: Unauthorized - * 403: - * description: Only owner can change roles - * 404: - * description: Member not found + * PUT /api/team/:memberId/role */ router.put('/:memberId/role', async (req: AuthenticatedRequest, res: Response) => { -router.put('/:memberId/role', requireRole('owner'), async (req: AuthenticatedRequest, res: Response) => { - try { - const { memberId } = req.params; - - const bodyValidation = updateRoleSchema.safeParse(req.body); - if (!bodyValidation.success) { - return res.status(400).json({ - success: false, - error: bodyValidation.error.errors.map((e) => e.message).join(', '), - }); - } - - const { role } = bodyValidation.data; + const { role } = validateRequest(updateRoleSchema, req.body); + const ctx = await resolveUserTeam(req.user!.id); - const ctx = await resolveUserTeam(req.user!.id); - - if (!ctx?.isOwner) { - return res.status(403).json({ success: false, error: 'Only the team owner can change member roles' }); - } + if (!ctx?.isOwner) { + throw new ForbiddenError('Only the team owner can change member roles'); + } - // Verify the member belongs to this team - const { data: member, error: fetchErr } = await supabase - .from('team_members') - .select('id, user_id, role') - .eq('id', memberId) - .eq('team_id', ctx.teamId) - .single(); + const { data: member, error: fetchErr } = await supabase + .from('team_members') + .select('id, user_id') + .eq('id', req.params.memberId) + .eq('team_id', ctx.teamId) + .maybeSingle(); - if (fetchErr || !member) { - return res.status(404).json({ success: false, error: 'Team member not found' }); - } + if (fetchErr || !member) { + throw new NotFoundError('Team member not found'); + } - const { data: updated, error: updateErr } = await supabase - .from('team_members') - .update({ role }) - .eq('id', memberId) - .select('id, user_id, role, joined_at') - .single(); + const { data: updated, error: updateErr } = await supabase + .from('team_members') + .update({ role }) + .eq('id', req.params.memberId) + .select('id, user_id, role, joined_at') + .single(); - if (updateErr) throw updateErr; + if (updateErr) throw updateErr; - res.json({ success: true, data: updated }); - } catch (error) { - logger.error('PUT /api/team/:memberId/role error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to update member role', - }); - } + res.json({ success: true, data: updated }); }); -// --------------------------------------------------------------------------- -// DELETE /api/team/:memberId — remove a team member (owner or admin) -// --------------------------------------------------------------------------- /** - * @openapi - * /api/team/{memberId}: - * delete: - * tags: [Team] - * summary: Remove a team member - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: memberId - * required: true - * schema: { type: string, format: uuid } - * responses: - * 200: - * description: Member removed - * 400: - * description: Cannot remove owner - * 401: - * description: Unauthorized - * 403: - * description: Forbidden - * 404: - * description: Member not found + * DELETE /api/team/:memberId */ router.delete('/:memberId', async (req: AuthenticatedRequest, res: Response) => { -router.delete('/:memberId', requireRole('owner', 'admin'), async (req: AuthenticatedRequest, res: Response) => { - try { - const { memberId } = req.params; - - const ctx = await resolveUserTeam(req.user!.id); + const ctx = await resolveUserTeam(req.user!.id); + if (!ctx || !canManageTeam(ctx)) { + throw new ForbiddenError('Only team owners and admins can remove members'); + } - if (!ctx) { - return res.status(403).json({ success: false, error: 'You are not part of a team' }); - } + const { data: member, error: fetchErr } = await supabase + .from('team_members') + .select('id, user_id') + .eq('id', req.params.memberId) + .eq('team_id', ctx.teamId) + .maybeSingle(); - if (!canManageTeam(ctx)) { - return res.status(403).json({ success: false, error: 'Only team owners and admins can remove members' }); - } + if (fetchErr || !member) { + throw new NotFoundError('Team member not found'); + } - // Verify member belongs to this team - const { data: member, error: fetchErr } = await supabase - .from('team_members') - .select('id, user_id') - .eq('id', memberId) - .eq('team_id', ctx.teamId) - .single(); + const { data: team } = await supabase + .from('teams') + .select('owner_id') + .eq('id', ctx.teamId) + .single(); - if (fetchErr || !member) { - return res.status(404).json({ success: false, error: 'Team member not found' }); - } + if (team?.owner_id === member.user_id) { + throw new BadRequestError('Cannot remove the team owner'); + } - // Prevent removing the owner via this endpoint - const { data: team } = await supabase - .from('teams') - .select('owner_id') - .eq('id', ctx.teamId) - .single(); + const { error: deleteErr } = await supabase + .from('team_members') + .delete() + .eq('id', req.params.memberId); - if (team?.owner_id === member.user_id) { - return res.status(400).json({ success: false, error: 'Cannot remove the team owner' }); - } + if (deleteErr) throw deleteErr; - const { error: deleteErr } = await supabase - .from('team_members') - .delete() - .eq('id', memberId); - - if (deleteErr) throw deleteErr; - - res.json({ success: true, message: 'Team member removed' }); - } catch (error) { - logger.error('DELETE /api/team/:memberId error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to remove team member', - }); - } + res.json({ success: true, message: 'Team member removed' }); }); export default router; diff --git a/backend/src/routes/webhooks.ts b/backend/src/routes/webhooks.ts index 3ce160f..74bfc3d 100644 --- a/backend/src/routes/webhooks.ts +++ b/backend/src/routes/webhooks.ts @@ -2,11 +2,9 @@ import { Router, Response } from 'express'; import { z } from 'zod'; import { webhookService } from '../services/webhook-service'; import { authenticate, AuthenticatedRequest } from '../middleware/auth'; -import logger from '../config/logger'; +import { validateRequest } from '../utils/validation'; const router = Router(); - -// All routes require authentication router.use(authenticate); const webhookEventSchema = z.enum([ @@ -31,169 +29,58 @@ const createWebhookSchema = z.object({ description: z.string().max(255, 'Description must not exceed 255 characters').optional(), }); -const updateWebhookSchema = z.object({ - url: z - .string() - .max(2000, 'URL must not exceed 2000 characters') - .url('Must be a valid URL') - .refine( - (val) => { try { const { protocol } = new URL(val); return protocol === 'http:' || protocol === 'https:'; } catch { return false; } }, - { message: 'URL must use http or https protocol' } - ) - .optional(), - events: z.array(webhookEventSchema).min(1, 'At least one event type is required').max(6, 'Maximum 6 event types per webhook').optional(), +const updateWebhookSchema = createWebhookSchema.partial().extend({ enabled: z.boolean().optional(), - description: z.string().max(255, 'Description must not exceed 255 characters').optional(), }); - /** * POST /api/webhooks - * Register a new webhook */ router.post('/', async (req: AuthenticatedRequest, res: Response) => { - try { - const validation = createWebhookSchema.safeParse(req.body); - if (!validation.success) { - return res.status(400).json({ - success: false, - error: validation.error.errors.map((e) => e.message).join(', '), - }); - } - - const webhook = await webhookService.registerWebhook(req.user!.id, req.body); - res.status(201).json({ - success: true, - data: webhook, - }); - } catch (error) { - logger.error('Create webhook error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to create webhook', - }); - } + const validatedData = validateRequest(createWebhookSchema, req.body); + const webhook = await webhookService.registerWebhook(req.user!.id, validatedData); + res.status(201).json({ success: true, data: webhook }); }); /** * GET /api/webhooks - * List all webhooks for the user */ router.get('/', async (req: AuthenticatedRequest, res: Response) => { - try { - const webhooks = await webhookService.listWebhooks(req.user!.id); - res.json({ - success: true, - data: webhooks, - }); - } catch (error) { - logger.error('List webhooks error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to list webhooks', - }); - } + const webhooks = await webhookService.listWebhooks(req.user!.id); + res.json({ success: true, data: webhooks }); }); /** * PUT /api/webhooks/:id - * Update a webhook */ router.put('/:id', async (req: AuthenticatedRequest, res: Response) => { - try { - const validation = updateWebhookSchema.safeParse(req.body); - if (!validation.success) { - return res.status(400).json({ - success: false, - error: validation.error.errors.map((e) => e.message).join(', '), - }); - } - - const webhook = await webhookService.updateWebhook( - req.user!.id, - Array.isArray(req.params.id) ? req.params.id[0] : req.params.id, - req.body - ); - res.json({ - success: true, - data: webhook, - }); - } catch (error) { - logger.error('Update webhook error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to update webhook', - }); - } + const validatedData = validateRequest(updateWebhookSchema, req.body); + const webhook = await webhookService.updateWebhook(req.user!.id, req.params.id, validatedData); + res.json({ success: true, data: webhook }); }); /** * DELETE /api/webhooks/:id - * Delete a webhook */ router.delete('/:id', async (req: AuthenticatedRequest, res: Response) => { - try { - await webhookService.deleteWebhook( - req.user!.id, - Array.isArray(req.params.id) ? req.params.id[0] : req.params.id - ); - res.json({ - success: true, - message: 'Webhook deleted', - }); - } catch (error) { - logger.error('Delete webhook error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to delete webhook', - }); - } + await webhookService.deleteWebhook(req.user!.id, req.params.id); + res.json({ success: true, message: 'Webhook deleted' }); }); /** * POST /api/webhooks/:id/test - * Trigger a test event */ router.post('/:id/test', async (req: AuthenticatedRequest, res: Response) => { - try { - const delivery = await webhookService.triggerTestEvent( - req.user!.id, - Array.isArray(req.params.id) ? req.params.id[0] : req.params.id - ); - res.json({ - success: true, - data: delivery, - }); - } catch (error) { - logger.error('Test webhook error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to trigger test event', - }); - } + const delivery = await webhookService.triggerTestEvent(req.user!.id, req.params.id); + res.json({ success: true, data: delivery }); }); /** * GET /api/webhooks/:id/deliveries - * Get delivery history for a webhook */ router.get('/:id/deliveries', async (req: AuthenticatedRequest, res: Response) => { - try { - const deliveries = await webhookService.getDeliveries( - req.user!.id, - Array.isArray(req.params.id) ? req.params.id[0] : req.params.id - ); - res.json({ - success: true, - data: deliveries, - }); - } catch (error) { - logger.error('Get deliveries error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to fetch deliveries', - }); - } + const deliveries = await webhookService.getDeliveries(req.user!.id, req.params.id); + res.json({ success: true, data: deliveries }); }); export default router; diff --git a/backend/src/swagger.ts b/backend/src/swagger.ts index d3b370c..813989a 100644 --- a/backend/src/swagger.ts +++ b/backend/src/swagger.ts @@ -39,13 +39,30 @@ const options: swaggerJSDoc.Options = { success: { type: 'boolean', example: true }, }, }, - ErrorResponse: { + ProblemDetails: { type: 'object', properties: { - success: { type: 'boolean', example: false }, - error: { type: 'string' }, + type: { type: 'string', format: 'uri', example: 'https://syncro.app/errors/not-found' }, + title: { type: 'string', example: 'Not Found' }, + status: { type: 'integer', example: 404 }, + detail: { type: 'string', example: 'Subscription with ID 123 not found.' }, + instance: { type: 'string', example: '/api/v1/subscriptions/123' }, + requestId: { type: 'string', example: 'req-abc-123' }, + errors: { + type: 'array', + items: { + type: 'object', + properties: { + field: { type: 'string' }, + message: { type: 'string' }, + }, + }, + }, }, }, + ErrorResponse: { + $ref: '#/components/schemas/ProblemDetails', + }, Pagination: { type: 'object', properties: { diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts new file mode 100644 index 0000000..89293d9 --- /dev/null +++ b/backend/src/utils/validation.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; +import { ValidationError } from '../errors'; + +/** + * Validates data against a Zod schema and throws a ValidationError if invalid. + * Optional location parameter indicates where the data came from (e.g. 'body', 'query'). + */ +export function validateRequest(schema: T, data: unknown, location: string = 'body'): z.infer { + const result = schema.safeParse(data); + + if (!result.success) { + const formattedErrors: Record = {}; + + result.error.errors.forEach((err: z.ZodIssue) => { + const field = err.path.join('.') || location; + if (!formattedErrors[field]) formattedErrors[field] = []; + formattedErrors[field].push(err.message); + }); + + throw new ValidationError(`Validation failed in ${location}`, formattedErrors); + } + + return result.data; +} + diff --git a/backend/status.txt b/backend/status.txt new file mode 100644 index 0000000..282985e --- /dev/null +++ b/backend/status.txt @@ -0,0 +1,23 @@ + M backend/package-lock.json + M backend/package.json + M backend/src/index.ts + M backend/src/routes/api-keys.ts + M backend/src/routes/audit.ts + M backend/src/routes/compliance.ts + M backend/src/routes/digest.ts + M backend/src/routes/exchange-rates.ts + M backend/src/routes/merchants.ts + M backend/src/routes/mfa.ts + M backend/src/routes/push-notifications.ts + M backend/src/routes/risk-score.ts + M backend/src/routes/simulation.ts + M backend/src/routes/subscriptions.ts + M backend/src/routes/team.ts + M backend/src/routes/webhooks.ts + M backend/src/swagger.ts + M sdk/src/errors.ts + M sdk/src/index.ts +?? backend/src/errors/ +?? backend/src/middleware/errorHandler.ts +?? backend/src/utils/validation.ts +?? backend/status.txt diff --git a/sdk/src/errors.ts b/sdk/src/errors.ts index 77e83c4..4a4a542 100644 --- a/sdk/src/errors.ts +++ b/sdk/src/errors.ts @@ -1,16 +1,52 @@ +/** + * RFC 7807 Problem Details for HTTP APIs + */ +export interface ProblemDetails { + type: string; + title: string; + status: number; + detail?: string; + instance?: string; + requestId?: string; + errors?: Array<{ + field: string; + message: string; + }>; +} + /** * Base error class for all Syncro SDK errors. */ export class SyncroError extends Error { - constructor( - message: string, - public readonly code: string, - ) { - super(message); - this.name = "SyncroError"; - // Maintains proper stack trace for where error was thrown (V8 engines) - if (Error.captureStackTrace) { - Error.captureStackTrace(this, this.constructor); + public readonly type: string; + public readonly title: string; + public readonly status: number; + public readonly detail?: string; + public readonly instance?: string; + public readonly requestId?: string; + public readonly validationErrors?: Array<{ field: string; message: string }>; + + constructor(problem: ProblemDetails | string, code?: string) { + if (typeof problem === "string") { + super(problem); + this.type = "about:blank"; + this.title = code || "SyncroError"; + this.status = 500; + this.detail = problem; + } else { + super(problem.detail || problem.title); + this.type = problem.type; + this.title = problem.title; + this.status = problem.status; + this.detail = problem.detail; + this.instance = problem.instance; + this.requestId = problem.requestId; + this.validationErrors = problem.errors; + } + this.name = this.constructor.name; + + if (Object.setPrototypeOf) { + Object.setPrototypeOf(this, new.target.prototype); } } } @@ -19,19 +55,26 @@ export class SyncroError extends Error { * Thrown when a requested resource is not found (HTTP 404). */ export class NotFoundError extends SyncroError { - constructor(message: string = "Resource not found") { - super(message, "NOT_FOUND"); - this.name = "NotFoundError"; + constructor(problem: ProblemDetails | string) { + super(problem, "NOT_FOUND"); } } /** - * Thrown when the API key is missing or invalid (HTTP 401). + * Thrown when authentication fails (HTTP 401). */ export class AuthenticationError extends SyncroError { - constructor(message: string = "Authentication failed") { - super(message, "AUTHENTICATION_ERROR"); - this.name = "AuthenticationError"; + constructor(problem: ProblemDetails | string) { + super(problem, "AUTHENTICATION_ERROR"); + } +} + +/** + * Thrown when access is forbidden (HTTP 403). + */ +export class ForbiddenError extends SyncroError { + constructor(problem: ProblemDetails | string) { + super(problem, "FORBIDDEN"); } } @@ -39,12 +82,8 @@ export class AuthenticationError extends SyncroError { * Thrown when the API rate limit is exceeded (HTTP 429). */ export class RateLimitError extends SyncroError { - constructor( - public readonly retryAfter: number, - message: string = "Rate limit exceeded", - ) { - super(message, "RATE_LIMIT_EXCEEDED"); - this.name = "RateLimitError"; + constructor(problem: ProblemDetails | string) { + super(problem, "RATE_LIMIT_EXCEEDED"); } } @@ -52,38 +91,52 @@ export class RateLimitError extends SyncroError { * Thrown when request input fails validation (HTTP 400). */ export class ValidationError extends SyncroError { - constructor( - message: string = "Validation failed", - public readonly field?: string, - ) { - super(message, "VALIDATION_ERROR"); - this.name = "ValidationError"; + constructor(problem: ProblemDetails | string) { + super(problem, "VALIDATION_ERROR"); + } +} + +/** + * Thrown when a conflict occurs (HTTP 409). + */ +export class ConflictError extends SyncroError { + constructor(problem: ProblemDetails | string) { + super(problem, "CONFLICT"); } } /** * Maps HTTP status codes and API error codes to the appropriate SDK error class. - * @param status HTTP status code from the response - * @param message Error message - * @param code Optional API error code string - * @param retryAfter Optional Retry-After value in seconds (for 429) */ export function createApiError( status: number, - message: string, - code?: string, + data: any, retryAfter?: number, ): SyncroError { + const problem: ProblemDetails = { + type: data?.type || "about:blank", + title: data?.title || "Unknown Error", + status: status, + detail: data?.detail || data?.error || data?.message || "An error occurred", + instance: data?.instance, + requestId: data?.requestId, + errors: data?.errors, + }; + switch (status) { + case 400: + return new ValidationError(problem); case 401: - return new AuthenticationError(message); + return new AuthenticationError(problem); + case 403: + return new ForbiddenError(problem); case 404: - return new NotFoundError(message); + return new NotFoundError(problem); + case 409: + return new ConflictError(problem); case 429: - return new RateLimitError(retryAfter ?? 60, message); - case 400: - return new ValidationError(message); + return new RateLimitError(problem); default: - return new SyncroError(message, code ?? `HTTP_${status}`); + return new SyncroError(problem); } } diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 2ac7272..3e0a1d9 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -1,917 +1,918 @@ -To resolve the merge conflicts in the `SyncroSDK`, I have combined the static `verifyWebhookSignature` method from the `webhook-system` feature branch with the comprehensive service methods (Subscription management, Analytics, Webhooks, and Notifications) added in `main`. - -```typescript -import axios, { type AxiosInstance } from "axios"; -import { EventEmitter } from "node:events"; -import * as crypto from "node:crypto"; -import type { - GiftCardEvent, - GiftCardEventType, - SyncroSDKConfig, - SyncroSDKInitConfig, - StellarWallet, - StellarKeypair, - RetryOptions, - CreateSubscriptionInput, - UpdateSubscriptionInput, - SubscriptionFilters, - SubscriptionRecord, - PaginatedResult, - AnalyticsSummary, - RenewalEvent, - CreateWebhookInput, - Webhook, - AppNotification, -} from "./types.js"; -import { - SyncroError, - NotFoundError, - AuthenticationError, - RateLimitError, - ValidationError, - createApiError, -} from "./errors.js"; - -export interface Subscription { - id: string; - name: string; - price: number; - billing_cycle: string; - status: string; - state: string; // Normalized - nextRenewal?: string; // Normalized - paymentMethod?: string; // Normalized - renewal_url?: string; - cancellation_url?: string; - [key: string]: any; -} - -export interface CancellationResult { - success: boolean; - status: "cancelled" | "failed" | "partial"; - subscription: Subscription; - redirectUrl?: string; - blockchain?: { - synced: boolean; - transactionHash?: string; - error?: string; - }; -} - -export class SyncroSDK extends EventEmitter { - private client: AxiosInstance; - private apiKey: string; - private baseURL: string; - private timeout: number; - private retryOptions: Required; - private batchConcurrency: number; - private enableLogging: boolean; - private wallet: StellarWallet | null; - private keypair: StellarKeypair | null; - - constructor(config: SyncroSDKConfig) { - super(); - - // Validate required config - this.validateConfig(config); - - // Set api key (required) - this.apiKey = config.apiKey; - - // Set base URL with default - this.baseURL = config.baseURL || "http://localhost:3001/api"; - - // Set timeout with default (30 seconds) - this.timeout = config.timeout ?? 30000; - - // Set retry options with defaults - this.retryOptions = { - maxRetries: config.retryOptions?.maxRetries ?? 3, - initialDelayMs: config.retryOptions?.initialDelayMs ?? 1000, - maxDelayMs: config.retryOptions?.maxDelayMs ?? 30000, - retryableStatusCodes: - config.retryOptions?.retryableStatusCodes ?? [408, 429, 500, 502, 503, 504], - }; - - // Set batch concurrency with default (5) - this.batchConcurrency = config.batchConcurrency ?? 5; - - // Set logging with default (false) - this.enableLogging = config.enableLogging ?? false; - - // Set optional wallet and keypair - this.wallet = config.wallet ?? null; - this.keypair = config.keypair ?? null; - - // Log initialization - if (this.enableLogging) { - console.log("[SyncroSDK] Initializing with config:", { - baseURL: this.baseURL, - timeout: this.timeout, - batchConcurrency: this.batchConcurrency, - retryOptions: this.retryOptions, - }); - } - - // Create axios client - this.client = this.createAxiosClient(); - } - - /** - * Validate SDK configuration - */ - private validateConfig(config: SyncroSDKConfig): void { - const errors: string[] = []; - - // Validate apiKey is provided and is a string - if (!config.apiKey || typeof config.apiKey !== "string") { - errors.push("apiKey is required and must be a non-empty string"); - } - - // Validate baseURL if provided - if (config.baseURL !== undefined) { - if (typeof config.baseURL !== "string") { - errors.push("baseURL must be a string"); - } else { - try { - new URL(config.baseURL); - } catch { - errors.push("baseURL must be a valid URL"); - } - } - } - - // Validate timeout if provided - if (config.timeout !== undefined) { - if (typeof config.timeout !== "number" || config.timeout < 0) { - errors.push("timeout must be a positive number"); - } - } - - // Validate retryOptions if provided - if (config.retryOptions) { - if (typeof config.retryOptions !== "object" || config.retryOptions === null) { - errors.push("retryOptions must be an object"); - } else { - const { maxRetries, initialDelayMs, maxDelayMs, retryableStatusCodes } = - config.retryOptions; - - if (maxRetries !== undefined && (typeof maxRetries !== "number" || maxRetries < 0)) { - errors.push("retryOptions.maxRetries must be a non-negative number"); - } - - if ( - initialDelayMs !== undefined && - (typeof initialDelayMs !== "number" || initialDelayMs < 0) - ) { - errors.push("retryOptions.initialDelayMs must be a non-negative number"); - } - - if (maxDelayMs !== undefined && (typeof maxDelayMs !== "number" || maxDelayMs < 0)) { - errors.push("retryOptions.maxDelayMs must be a non-negative number"); - } - - if (retryableStatusCodes !== undefined) { - if (!Array.isArray(retryableStatusCodes)) { - errors.push("retryOptions.retryableStatusCodes must be an array"); - } else if (!retryableStatusCodes.every((code) => typeof code === "number")) { - errors.push("retryOptions.retryableStatusCodes must contain only numbers"); - } - } - } - } - - // Validate batchConcurrency if provided - if (config.batchConcurrency !== undefined) { - if (typeof config.batchConcurrency !== "number" || config.batchConcurrency < 1) { - errors.push("batchConcurrency must be a positive number"); - } - } - - // Validate enableLogging if provided - if (config.enableLogging !== undefined && typeof config.enableLogging !== "boolean") { - errors.push("enableLogging must be a boolean"); - } - - if (errors.length > 0) { - throw new Error(`Invalid SDK configuration: ${errors.join("; ")}`); - } - } - - /** - * Create axios client with configured options - */ - private createAxiosClient(): AxiosInstance { - const headers: Record = { - "Content-Type": "application/json", - Authorization: `Bearer ${this.apiKey}`, - }; - - const client = axios.create({ - baseURL: this.baseURL, - timeout: this.timeout, - headers, - }); - - // Add response interceptor for retry logic - client.interceptors.response.use( - (response: any) => response, - async (error: any) => { - const config = error.config; - - // Initialize retry count - if (!config._retryCount) { - config._retryCount = 0; - } - - // Check if should retry - const shouldRetry = - config._retryCount < this.retryOptions.maxRetries && - error.response && - this.retryOptions.retryableStatusCodes.includes(error.response.status); - - if (shouldRetry) { - config._retryCount++; - - // Calculate exponential backoff delay - const delay = Math.min( - this.retryOptions.initialDelayMs * Math.pow(2, config._retryCount - 1), - this.retryOptions.maxDelayMs, - ); - - if (this.enableLogging) { - console.log( - `[SyncroSDK] Retrying request (attempt ${config._retryCount}/${this.retryOptions.maxRetries}) after ${delay}ms`, - ); - } - - await new Promise((resolve) => setTimeout(resolve, delay)); - return client(config); - } - - return Promise.reject(error); - }, - ); - - return client; - } - - /** - * Get batch concurrency limit - */ - getBatchConcurrency(): number { - return this.batchConcurrency; - } - - /** - * Log message if logging is enabled - */ - protected log(...args: any[]): void { - if (this.enableLogging) { - console.log("[SyncroSDK]", ...args); - } - } - - /** - * Cancel a subscription programmatically - * @param subscriptionId The ID of the subscription to cancel - * @returns Cancellation result including status and optional redirect link - */ - async cancelSubscription( - subscriptionId: string, - ): Promise { - try { - this.log("Cancelling subscription:", subscriptionId); - this.emit("cancelling", { subscriptionId }); - - const response = await this.client.post( - `/subscriptions/${subscriptionId}/cancel`, - ); - const { data, blockchain } = response.data; - - const result: CancellationResult = { - success: true, - status: "cancelled", - subscription: data, - redirectUrl: data.cancellation_url || data.renewal_url, - blockchain: blockchain, - }; - - this.log("Subscription cancelled successfully:", subscriptionId); - this.emit("success", result); - return result; - } catch (error: any) { - const errorMessage = error.response?.data?.error || error.message; - this.log("Error cancelling subscription:", subscriptionId, errorMessage); - - const failedResult: any = { - success: false, - status: "failed", - error: errorMessage, - }; - - this.emit("failure", { subscriptionId, error: errorMessage }); - this.emit("error", new Error(errorMessage)); - throw new Error(`Cancellation failed: ${errorMessage}`); - } - } - - /** - * Get subcription details - */ - async getSubscription(subscriptionId: string): Promise { - this.log("Fetching subscription:", subscriptionId); - const response = await this.client.get(`/subscriptions/${subscriptionId}`); - return this.normalizeSubscription(response.data.data); - } - - /** - * Fetch all user subscriptions with normalization and offline support - */ - async getUserSubscriptions(): Promise { - if (!this.apiKey) { - throw new Error("API Key is required to fetch subscriptions"); - } - - this.log("Fetching all user subscriptions"); - const cacheKey = `syncro_subs_${this.apiKey}`; - - try { - this.log("Fetching user subscriptions"); - let allSubscriptions: any[] = []; - let offset = 0; - const limit = 50; - let hasMore = true; - - while (hasMore) { - this.log("Fetching subscriptions batch", { offset, limit }); - const response = await this.client.get("/subscriptions", { - params: { limit, offset }, - }); - - const { data, pagination } = response.data; - allSubscriptions = [...allSubscriptions, ...data]; - - if ( - pagination && - data.length > 0 && - allSubscriptions.length < pagination.total - ) { - offset += limit; - } else { - hasMore = false; - } - } - - const normalized = allSubscriptions.map((sub) => - this.normalizeSubscription(sub), - ); - - // Update cache - this.updateCache(cacheKey, normalized); - this.log(`Fetched ${normalized.length} subscriptions`); - - this.log("User subscriptions fetched successfully", { - count: normalized.length, - }); - return normalized; - } catch (error) { - // Offline/Error support: Check cache - const cached = this.getCache(cacheKey); - if (cached) { - this.log( - "Network error, returning cached subscriptions.", - ); - return cached; - } - this.log("Failed to fetch subscriptions", - error instanceof Error ? error.message : String(error), - ); - throw error; - } - } - - private normalizeSubscription(sub: any): Subscription { - return { - ...sub, - state: sub.status, - nextRenewal: sub.next_billing_date, - paymentMethod: sub.payment_method || "Credit Card", // Default if not present - }; - } - - private updateCache(key: string, data: Subscription[]): void { - try { - if (typeof window !== "undefined" && window.localStorage) { - localStorage.setItem( - key, - JSON.stringify({ - data, - timestamp: Date.now(), - }), - ); - this.log("Cache updated for key:", key); - } - } catch (e) { - // Silently fail if storage is full or unavailable - this.log("Cache update failed:", e); - } - } - - private getCache(key: string): Subscription[] | null { - try { - if (typeof window !== "undefined" && window.localStorage) { - const cached = localStorage.getItem(key); - if (cached) { - this.log("Cache hit for key:", key); - return JSON.parse(cached).data; - } - } - } catch (e) { - this.log("Cache read failed:", e); - return null; - } - return null; - } - - /** - * Verify a webhook signature - * @param payload The raw request body as a string - * @param signature The X-Syncro-Signature header value - * @param secret The webhook secret (whsec_...) - * @returns boolean indicating if the signature is valid - */ - static verifyWebhookSignature(payload: string, signature: string, secret: string): boolean { - if (!signature || !secret || !payload) return false; - - const [timestampPart, signaturePart] = signature.split(","); - if (!timestampPart || !signaturePart) return false; - - const timestamp = timestampPart.split("=")[1]; - const receivedSignature = signaturePart.split("=")[1]; - - if (!timestamp || !receivedSignature) return false; - - // Verify timestamp is within 5 minutes (300 seconds) - const now = Math.floor(Date.now() / 1000); - const ts = parseInt(timestamp, 10); - if (isNaN(ts) || Math.abs(now - ts) > 300) { - return false; - } - - const signedPayload = `${timestamp}.${payload}`; - const expectedSignature = crypto - .createHmac("sha256", secret) - .update(signedPayload) - .digest("hex"); - - return crypto.timingSafeEqual( - Buffer.from(receivedSignature, "hex"), - Buffer.from(expectedSignature, "hex") - ); - } - - // ───────────────────────────────────────────────────────────────────────── - // Private helper: map Axios errors to typed SDK errors - // ───────────────────────────────────────────────────────────────────────── - - private handleApiError(error: any): never { - if (error.response) { - const { status, data, headers } = error.response; - const message: string = - data?.error || data?.message || error.message || "Unknown API error"; - const code: string | undefined = data?.code; - const retryAfter = headers?.["retry-after"] - ? parseInt(headers["retry-after"], 10) - : undefined; - throw createApiError(status, message, code, retryAfter); - } - // Network / timeout errors - throw new SyncroError(error.message || "Network error", "NETWORK_ERROR"); - } - - // ───────────────────────────────────────────────────────────────────────── - // Subscription management - // ───────────────────────────────────────────────────────────────────────── - - /** - * Create a new subscription. - */ - async createSubscription( - input: CreateSubscriptionInput, - ): Promise { - try { - this.log("Creating subscription:", input.name); - const response = await this.client.post("/subscriptions", input); - const record: SubscriptionRecord = response.data.data; - this.emit("subscription:created", record); - return record; - } catch (error: any) { - this.handleApiError(error); - } - } - - /** - * Update an existing subscription by ID. - */ - async updateSubscription( - id: string, - input: UpdateSubscriptionInput, - ): Promise { - try { - this.log("Updating subscription:", id); - const response = await this.client.patch(`/subscriptions/${id}`, input); - const record: SubscriptionRecord = response.data.data; - this.emit("subscription:updated", record); - return record; - } catch (error: any) { - this.handleApiError(error); - } - } - - /** - * Delete a subscription by ID. - */ - async deleteSubscription(id: string): Promise { - try { - this.log("Deleting subscription:", id); - await this.client.delete(`/subscriptions/${id}`); - this.emit("subscription:deleted", { id }); - } catch (error: any) { - this.handleApiError(error); - } - } - - /** - * List subscriptions with optional filtering and pagination. - */ - async listSubscriptions( - options?: SubscriptionFilters, - ): Promise> { - try { - this.log("Listing subscriptions with options:", options); - const pageSize = options?.limit ?? 20; - const page = options?.page ?? 1; - const offset = (page - 1) * pageSize; - - const params: Record = { - limit: pageSize, - offset, - }; - if (options?.status) params.status = options.status; - if (options?.category) params.category = options.category; - - const response = await this.client.get("/subscriptions", { params }); - const { data, pagination } = response.data; - const total: number = pagination?.total ?? data.length; - - return { - data, - total, - hasMore: offset + pageSize < total, - }; - } catch (error: any) { - this.handleApiError(error); - } - } - - // ───────────────────────────────────────────────────────────────────────── - // Analytics - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get an analytics summary computed from the user's active subscriptions. - * Derives totals locally from the subscription list so it works without a - * dedicated analytics endpoint on the backend. - */ - async getAnalyticsSummary(): Promise { - try { - this.log("Fetching analytics summary"); - // Fetch all subscriptions (up to 500) to compute summary locally - const response = await this.client.get("/subscriptions", { - params: { limit: 500, offset: 0 }, - }); - const subs: SubscriptionRecord[] = response.data.data ?? []; - - const statusCounts: Record = {}; - const categoryCounts: Record = {}; - let totalMonthlyCost = 0; - let totalAnnualCost = 0; - let totalActive = 0; - let upcomingRenewals = 0; - const now = new Date(); - const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); - - for (const sub of subs) { - // Tally by status - statusCounts[sub.status] = (statusCounts[sub.status] ?? 0) + 1; - // Tally by category - const cat = sub.category ?? "Uncategorized"; - categoryCounts[cat] = (categoryCounts[cat] ?? 0) + 1; - - if (sub.status === "active" || sub.status === "trial") { - totalActive++; - // Normalise cost to monthly - let monthly = sub.price; - if (sub.billing_cycle === "yearly") monthly = sub.price / 12; - if (sub.billing_cycle === "quarterly") monthly = sub.price / 3; - totalMonthlyCost += monthly; - totalAnnualCost += monthly * 12; - - // Check for upcoming renewals in next 7 days - if (sub.next_billing_date) { - const renewal = new Date(sub.next_billing_date); - if (renewal >= now && renewal <= sevenDaysFromNow) { - upcomingRenewals++; - } - } - } - } - - return { - totalActiveSubscriptions: totalActive, - totalMonthlyCost: Math.round(totalMonthlyCost * 100) / 100, - totalAnnualCost: Math.round(totalAnnualCost * 100) / 100, - subscriptionsByStatus: statusCounts as Record, - subscriptionsByCategory: categoryCounts, - upcomingRenewals, - }; - } catch (error: any) { - this.handleApiError(error); - } - } - - /** - * Get renewal history for a specific subscription. - * Uses the billing simulation endpoint to project past/future renewals. - */ - async getRenewalHistory(subscriptionId: string): Promise { - try { - this.log("Fetching renewal history for subscription:", subscriptionId); - // Use the billing simulation for a 365-day window to get renewal events - const response = await this.client.get("/simulation", { - params: { days: 365 }, - }); - const projections: any[] = response.data?.data?.projections ?? []; - // Filter to this subscription - const events: RenewalEvent[] = projections - .filter((p: any) => p.subscriptionId === subscriptionId) - .map((p: any) => ({ - id: `${p.subscriptionId}-${p.projectedDate}`, - subscriptionId: p.subscriptionId, - subscriptionName: p.subscriptionName, - amount: p.amount, - billingCycle: p.billingCycle, - renewedAt: p.projectedDate, - status: "success" as const, - })); - return events; - } catch (error: any) { - this.handleApiError(error); - } - } - - // ───────────────────────────────────────────────────────────────────────── - // Webhook management (client-side registry stored via API) - // ───────────────────────────────────────────────────────────────────────── - - /** - * Register a new webhook endpoint. - */ - async createWebhook(input: CreateWebhookInput): Promise { - try { - this.log("Creating webhook for URL:", input.url); - const response = await this.client.post("/webhooks", input); - const webhook: Webhook = response.data.data; - this.emit("webhook:created", webhook); - return webhook; - } catch (error: any) { - this.handleApiError(error); - } - } - - /** - * List all registered webhooks. - */ - async listWebhooks(): Promise { - try { - this.log("Listing webhooks"); - const response = await this.client.get("/webhooks"); - return response.data.data as Webhook[]; - } catch (error: any) { - this.handleApiError(error); - } - } - - /** - * Delete a webhook by ID. - */ - async deleteWebhook(id: string): Promise { - try { - this.log("Deleting webhook:", id); - await this.client.delete(`/webhooks/${id}`); - this.emit("webhook:deleted", { id }); - } catch (error: any) { - this.handleApiError(error); - } - } - - // ───────────────────────────────────────────────────────────────────────── - // Notifications - // ───────────────────────────────────────────────────────────────────────── - - /** - * Retrieve in-app notifications for the authenticated user. - * @param options.unreadOnly When true, only returns unread notifications. - */ - async getNotifications( - options?: { unreadOnly?: boolean }, - ): Promise { - try { - this.log("Fetching notifications, unreadOnly:", options?.unreadOnly); - const response = await this.client.get("/notifications", { - params: options?.unreadOnly ? { is_read: false } : {}, - }); - const raw: any[] = response.data.data ?? response.data ?? []; - // Normalise snake_case → camelCase - return raw.map((n: any) => ({ - id: n.id, - type: n.type, - title: n.title, - message: n.message, - subscriptionId: n.subscription_id ?? n.subscriptionId ?? null, - isRead: n.is_read ?? n.isRead ?? false, - createdAt: n.created_at ?? n.createdAt, - })); - } catch (error: any) { - this.handleApiError(error); - } - } - - /** - * Mark a specific notification as read. - */ - async markNotificationRead(id: string): Promise { - try { - this.log("Marking notification as read:", id); - await this.client.patch(`/notifications/${id}`, { is_read: true }); - this.emit("notification:read", { id }); - } catch (error: any) { - this.handleApiError(error); - } - } -} - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function hasFunctionOrStringPublicKey( - value: unknown, -): value is string | (() => string) { - return typeof value === "string" || typeof value === "function"; -} - -function validateInitConfig(config: SyncroSDKInitConfig): void { - const errors: string[] = []; - - if (!isObject(config)) { - throw new Error( - "Invalid SDK initialization config: config must be an object.", - ); - } - - // Validate apiKey is provided - if (!config.apiKey || typeof config.apiKey !== "string") { - errors.push("apiKey is required and must be a non-empty string"); - } - - // Handle both baseURL and backendApiBaseUrl for backwards compatibility - const baseUrl = config.baseURL || config.backendApiBaseUrl; - - if (baseUrl) { - if (typeof baseUrl !== "string" || baseUrl.trim().length === 0) { - errors.push("baseURL must be a non-empty string"); - } else { - try { - new URL(baseUrl); - } catch { - errors.push("baseURL must be a valid URL"); - } - } - } - - if (!config.wallet && !config.keypair) { - errors.push("Provide either a wallet object or a keypair"); - } - - if (config.wallet && !isObject(config.wallet)) { - errors.push("wallet must be an object"); - } - - if (config.keypair) { - if (!isObject(config.keypair)) { - errors.push("keypair must be an object"); - } else if (!hasFunctionOrStringPublicKey(config.keypair.publicKey)) { - errors.push( - "keypair.publicKey must be a string or a function returning a string", - ); - } - } - - if (errors.length > 0) { - throw new Error(`Invalid SDK initialization config: ${errors.join("; ")}`); - } -} - -function getSignerPublicKey( - wallet?: StellarWallet, - keypair?: StellarKeypair, -): string | undefined { - if (wallet?.publicKey) { - return typeof wallet.publicKey === "function" - ? wallet.publicKey() - : wallet.publicKey; - } - - if (keypair?.publicKey) { - return typeof keypair.publicKey === "function" - ? keypair.publicKey() - : keypair.publicKey; - } - - return undefined; -} - -/** - * Create and initialize a Syncro SDK instance - * @param config SDK configuration - * @returns Initialized SyncroSDK instance - * @throws Error if configuration is invalid - */ -export function init(config: SyncroSDKInitConfig): SyncroSDK { - validateInitConfig(config); - - // Use baseURL if provided, otherwise fall back to backendApiBaseUrl for backwards compatibility - const finalConfig: SyncroSDKConfig = { - apiKey: config.apiKey, - ...(config.baseURL !== undefined && { baseURL: config.baseURL }), - ...(config.baseURL === undefined && config.backendApiBaseUrl !== undefined && { - baseURL: config.backendApiBaseUrl, - }), - ...(config.timeout !== undefined && { timeout: config.timeout }), - ...(config.retryOptions !== undefined && { retryOptions: config.retryOptions }), - ...(config.batchConcurrency !== undefined && { - batchConcurrency: config.batchConcurrency, - }), - ...(config.enableLogging !== undefined && { enableLogging: config.enableLogging }), - ...(config.wallet !== undefined && { wallet: config.wallet }), - ...(config.keypair !== undefined && { keypair: config.keypair }), - }; - - const sdk = new SyncroSDK(finalConfig); - - const readyPayload = { - baseURL: finalConfig.baseURL, - publicKey: getSignerPublicKey(config.wallet, config.keypair), - }; - - queueMicrotask(() => { - sdk.emit("ready", readyPayload); - }); - - return sdk; -} - -export default SyncroSDK; -export type { - GiftCardEvent, - GiftCardEventType, - SyncroSDKConfig, - SyncroSDKInitConfig, - RetryOptions, - StellarWallet, - StellarKeypair, - // Subscription types - CreateSubscriptionInput, - UpdateSubscriptionInput, - SubscriptionFilters, - SubscriptionRecord, - PaginatedResult, - // Analytics types - AnalyticsSummary, - RenewalEvent, - // Webhook types - CreateWebhookInput, - Webhook, - // Notification types - AppNotification, -} from "./types.js"; -export { - SyncroError, - NotFoundError, - AuthenticationError, - RateLimitError, - ValidationError, -} from "./errors.js"; -``` \ No newline at end of file +import axios, { type AxiosInstance } from "axios"; +import { EventEmitter } from "node:events"; +import * as crypto from "node:crypto"; +import type { + GiftCardEvent, + GiftCardEventType, + SyncroSDKConfig, + SyncroSDKInitConfig, + StellarWallet, + StellarKeypair, + RetryOptions, + CreateSubscriptionInput, + UpdateSubscriptionInput, + SubscriptionFilters, + SubscriptionRecord, + PaginatedResult, + AnalyticsSummary, + RenewalEvent, + CreateWebhookInput, + Webhook, + AppNotification, +} from "./types.js"; +import { + SyncroError, + NotFoundError, + AuthenticationError, + RateLimitError, + ValidationError, + ConflictError, + ForbiddenError, + createApiError, +} from "./errors.js"; + +export interface Subscription { + id: string; + name: string; + price: number; + billing_cycle: string; + status: string; + state: string; // Normalized + nextRenewal?: string; // Normalized + paymentMethod?: string; // Normalized + renewal_url?: string; + cancellation_url?: string; + [key: string]: any; +} + +export interface CancellationResult { + success: boolean; + status: "cancelled" | "failed" | "partial"; + subscription: Subscription; + redirectUrl?: string; + blockchain?: { + synced: boolean; + transactionHash?: string; + error?: string; + }; +} + +export class SyncroSDK extends EventEmitter { + private client: AxiosInstance; + private apiKey: string; + private baseURL: string; + private timeout: number; + private retryOptions: Required; + private batchConcurrency: number; + private enableLogging: boolean; + private wallet: StellarWallet | null; + private keypair: StellarKeypair | null; + + constructor(config: SyncroSDKConfig) { + super(); + + // Validate required config + this.validateConfig(config); + + // Set api key (required) + this.apiKey = config.apiKey; + + // Set base URL with default + this.baseURL = config.baseURL || "http://localhost:3001/api"; + + // Set timeout with default (30 seconds) + this.timeout = config.timeout ?? 30000; + + // Set retry options with defaults + this.retryOptions = { + maxRetries: config.retryOptions?.maxRetries ?? 3, + initialDelayMs: config.retryOptions?.initialDelayMs ?? 1000, + maxDelayMs: config.retryOptions?.maxDelayMs ?? 30000, + retryableStatusCodes: + config.retryOptions?.retryableStatusCodes ?? [408, 429, 500, 502, 503, 504], + }; + + // Set batch concurrency with default (5) + this.batchConcurrency = config.batchConcurrency ?? 5; + + // Set logging with default (false) + this.enableLogging = config.enableLogging ?? false; + + // Set optional wallet and keypair + this.wallet = config.wallet ?? null; + this.keypair = config.keypair ?? null; + + // Log initialization + if (this.enableLogging) { + console.log("[SyncroSDK] Initializing with config:", { + baseURL: this.baseURL, + timeout: this.timeout, + batchConcurrency: this.batchConcurrency, + retryOptions: this.retryOptions, + }); + } + + // Create axios client + this.client = this.createAxiosClient(); + } + + /** + * Validate SDK configuration + */ + private validateConfig(config: SyncroSDKConfig): void { + const errors: string[] = []; + + // Validate apiKey is provided and is a string + if (!config.apiKey || typeof config.apiKey !== "string") { + errors.push("apiKey is required and must be a non-empty string"); + } + + // Validate baseURL if provided + if (config.baseURL !== undefined) { + if (typeof config.baseURL !== "string") { + errors.push("baseURL must be a string"); + } else { + try { + new URL(config.baseURL); + } catch { + errors.push("baseURL must be a valid URL"); + } + } + } + + // Validate timeout if provided + if (config.timeout !== undefined) { + if (typeof config.timeout !== "number" || config.timeout < 0) { + errors.push("timeout must be a positive number"); + } + } + + // Validate retryOptions if provided + if (config.retryOptions) { + if (typeof config.retryOptions !== "object" || config.retryOptions === null) { + errors.push("retryOptions must be an object"); + } else { + const { maxRetries, initialDelayMs, maxDelayMs, retryableStatusCodes } = + config.retryOptions; + + if (maxRetries !== undefined && (typeof maxRetries !== "number" || maxRetries < 0)) { + errors.push("retryOptions.maxRetries must be a non-negative number"); + } + + if ( + initialDelayMs !== undefined && + (typeof initialDelayMs !== "number" || initialDelayMs < 0) + ) { + errors.push("retryOptions.initialDelayMs must be a non-negative number"); + } + + if (maxDelayMs !== undefined && (typeof maxDelayMs !== "number" || maxDelayMs < 0)) { + errors.push("retryOptions.maxDelayMs must be a non-negative number"); + } + + if (retryableStatusCodes !== undefined) { + if (!Array.isArray(retryableStatusCodes)) { + errors.push("retryOptions.retryableStatusCodes must be an array"); + } else if (!retryableStatusCodes.every((code) => typeof code === "number")) { + errors.push("retryOptions.retryableStatusCodes must contain only numbers"); + } + } + } + } + + // Validate batchConcurrency if provided + if (config.batchConcurrency !== undefined) { + if (typeof config.batchConcurrency !== "number" || config.batchConcurrency < 1) { + errors.push("batchConcurrency must be a positive number"); + } + } + + // Validate enableLogging if provided + if (config.enableLogging !== undefined && typeof config.enableLogging !== "boolean") { + errors.push("enableLogging must be a boolean"); + } + + if (errors.length > 0) { + throw new Error(`Invalid SDK configuration: ${errors.join("; ")}`); + } + } + + /** + * Create axios client with configured options + */ + private createAxiosClient(): AxiosInstance { + const headers: Record = { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + }; + + const client = axios.create({ + baseURL: this.baseURL, + timeout: this.timeout, + headers, + }); + + // Add response interceptor for retry logic + client.interceptors.response.use( + (response: any) => response, + async (error: any) => { + const config = error.config; + + // Initialize retry count + if (!config._retryCount) { + config._retryCount = 0; + } + + // Check if should retry + const shouldRetry = + config._retryCount < this.retryOptions.maxRetries && + error.response && + this.retryOptions.retryableStatusCodes.includes(error.response.status); + + if (shouldRetry) { + config._retryCount++; + + // Calculate exponential backoff delay + const delay = Math.min( + this.retryOptions.initialDelayMs * Math.pow(2, config._retryCount - 1), + this.retryOptions.maxDelayMs, + ); + + if (this.enableLogging) { + console.log( + `[SyncroSDK] Retrying request (attempt ${config._retryCount}/${this.retryOptions.maxRetries}) after ${delay}ms`, + ); + } + + await new Promise((resolve) => setTimeout(resolve, delay)); + return client(config); + } + + return Promise.reject(error); + }, + ); + + return client; + } + + /** + * Get batch concurrency limit + */ + getBatchConcurrency(): number { + return this.batchConcurrency; + } + + /** + * Log message if logging is enabled + */ + protected log(...args: any[]): void { + if (this.enableLogging) { + console.log("[SyncroSDK]", ...args); + } + } + + /** + * Cancel a subscription programmatically + * @param subscriptionId The ID of the subscription to cancel + * @returns Cancellation result including status and optional redirect link + */ + async cancelSubscription( + subscriptionId: string, + ): Promise { + try { + this.log("Cancelling subscription:", subscriptionId); + this.emit("cancelling", { subscriptionId }); + + const response = await this.client.post( + `/subscriptions/${subscriptionId}/cancel`, + ); + const { data, blockchain } = response.data; + + const result: CancellationResult = { + success: true, + status: "cancelled", + subscription: data, + redirectUrl: data.cancellation_url || data.renewal_url, + blockchain: blockchain, + }; + + this.log("Subscription cancelled successfully:", subscriptionId); + this.emit("success", result); + return result; + } catch (error: any) { + this.handleApiError(error); + } + } + + /** + * Get subcription details + */ + async getSubscription(subscriptionId: string): Promise { + try { + this.log("Fetching subscription:", subscriptionId); + const response = await this.client.get(`/subscriptions/${subscriptionId}`); + return this.normalizeSubscription(response.data.data); + } catch (error: any) { + this.handleApiError(error); + } + } + + /** + * Fetch all user subscriptions with normalization and offline support + */ + async getUserSubscriptions(): Promise { + if (!this.apiKey) { + throw new Error("API Key is required to fetch subscriptions"); + } + + this.log("Fetching all user subscriptions"); + const cacheKey = `syncro_subs_${this.apiKey}`; + + try { + this.log("Fetching user subscriptions"); + let allSubscriptions: any[] = []; + let offset = 0; + const limit = 50; + let hasMore = true; + + while (hasMore) { + this.log("Fetching subscriptions batch", { offset, limit }); + const response = await this.client.get("/subscriptions", { + params: { limit, offset }, + }); + + const { data, pagination } = response.data; + allSubscriptions = [...allSubscriptions, ...data]; + + if ( + pagination && + data.length > 0 && + allSubscriptions.length < pagination.total + ) { + offset += limit; + } else { + hasMore = false; + } + } + + const normalized = allSubscriptions.map((sub) => + this.normalizeSubscription(sub), + ); + + // Update cache + this.updateCache(cacheKey, normalized); + this.log(`Fetched ${normalized.length} subscriptions`); + + this.log("User subscriptions fetched successfully", { + count: normalized.length, + }); + return normalized; + } catch (error) { + // Offline/Error support: Check cache + const cached = this.getCache(cacheKey); + if (cached) { + this.log( + "Network error, returning cached subscriptions.", + ); + return cached; + } + this.log("Failed to fetch subscriptions", + error instanceof Error ? error.message : String(error), + ); + throw error; + } + } + + private normalizeSubscription(sub: any): Subscription { + return { + ...sub, + state: sub.status, + nextRenewal: sub.next_billing_date, + paymentMethod: sub.payment_method || "Credit Card", // Default if not present + }; + } + + private updateCache(key: string, data: Subscription[]): void { + try { + if (typeof window !== "undefined" && window.localStorage) { + localStorage.setItem( + key, + JSON.stringify({ + data, + timestamp: Date.now(), + }), + ); + this.log("Cache updated for key:", key); + } + } catch (e) { + // Silently fail if storage is full or unavailable + this.log("Cache update failed:", e); + } + } + + private getCache(key: string): Subscription[] | null { + try { + if (typeof window !== "undefined" && window.localStorage) { + const cached = localStorage.getItem(key); + if (cached) { + this.log("Cache hit for key:", key); + return JSON.parse(cached).data; + } + } + } catch (e) { + this.log("Cache read failed:", e); + return null; + } + return null; + } + + /** + * Verify a webhook signature + * @param payload The raw request body as a string + * @param signature The X-Syncro-Signature header value + * @param secret The webhook secret (whsec_...) + * @returns boolean indicating if the signature is valid + */ + static verifyWebhookSignature(payload: string, signature: string, secret: string): boolean { + if (!signature || !secret || !payload) return false; + + const [timestampPart, signaturePart] = signature.split(","); + if (!timestampPart || !signaturePart) return false; + + const timestamp = timestampPart.split("=")[1]; + const receivedSignature = signaturePart.split("=")[1]; + + if (!timestamp || !receivedSignature) return false; + + // Verify timestamp is within 5 minutes (300 seconds) + const now = Math.floor(Date.now() / 1000); + const ts = parseInt(timestamp, 10); + if (isNaN(ts) || Math.abs(now - ts) > 300) { + return false; + } + + const signedPayload = `${timestamp}.${payload}`; + const expectedSignature = crypto + .createHmac("sha256", secret) + .update(signedPayload) + .digest("hex"); + + return crypto.timingSafeEqual( + Buffer.from(receivedSignature, "hex"), + Buffer.from(expectedSignature, "hex") + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Private helper: map Axios errors to typed SDK errors + // ───────────────────────────────────────────────────────────────────────── + + private handleApiError(error: any): never { + if (error.response) { + const { status, data, headers } = error.response; + const retryAfter = headers?.["retry-after"] + ? parseInt(headers["retry-after"], 10) + : undefined; + + const apiError = createApiError(status, data, retryAfter); + + this.log(`API Error: ${status}`, apiError.detail); + this.emit("error", apiError); + this.emit("failure", { error: apiError.detail, status }); + + throw apiError; + } + + // Network / timeout / setup errors + const networkError = new SyncroError(error.message || "Network error", "NETWORK_ERROR"); + this.emit("error", networkError); + throw networkError; + } + + // ───────────────────────────────────────────────────────────────────────── + // Subscription management + // ───────────────────────────────────────────────────────────────────────── + + /** + * Create a new subscription. + */ + async createSubscription( + input: CreateSubscriptionInput, + ): Promise { + try { + this.log("Creating subscription:", input.name); + const response = await this.client.post("/subscriptions", input); + const record: SubscriptionRecord = response.data.data; + this.emit("subscription:created", record); + return record; + } catch (error: any) { + this.handleApiError(error); + } + } + + /** + * Update an existing subscription by ID. + */ + async updateSubscription( + id: string, + input: UpdateSubscriptionInput, + ): Promise { + try { + this.log("Updating subscription:", id); + const response = await this.client.patch(`/subscriptions/${id}`, input); + const record: SubscriptionRecord = response.data.data; + this.emit("subscription:updated", record); + return record; + } catch (error: any) { + this.handleApiError(error); + } + } + + /** + * Delete a subscription by ID. + */ + async deleteSubscription(id: string): Promise { + try { + this.log("Deleting subscription:", id); + await this.client.delete(`/subscriptions/${id}`); + this.emit("subscription:deleted", { id }); + } catch (error: any) { + this.handleApiError(error); + } + } + + /** + * List subscriptions with optional filtering and pagination. + */ + async listSubscriptions( + options?: SubscriptionFilters, + ): Promise> { + try { + this.log("Listing subscriptions with options:", options); + const pageSize = options?.limit ?? 20; + const page = options?.page ?? 1; + const offset = (page - 1) * pageSize; + + const params: Record = { + limit: pageSize, + offset, + }; + if (options?.status) params.status = options.status; + if (options?.category) params.category = options.category; + + const response = await this.client.get("/subscriptions", { params }); + const { data, pagination } = response.data; + const total: number = pagination?.total ?? data.length; + + return { + data, + total, + hasMore: offset + pageSize < total, + }; + } catch (error: any) { + this.handleApiError(error); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Analytics + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get an analytics summary computed from the user's active subscriptions. + * Derives totals locally from the subscription list so it works without a + * dedicated analytics endpoint on the backend. + */ + async getAnalyticsSummary(): Promise { + try { + this.log("Fetching analytics summary"); + // Fetch all subscriptions (up to 500) to compute summary locally + const response = await this.client.get("/subscriptions", { + params: { limit: 500, offset: 0 }, + }); + const subs: SubscriptionRecord[] = response.data.data ?? []; + + const statusCounts: Record = {}; + const categoryCounts: Record = {}; + let totalMonthlyCost = 0; + let totalAnnualCost = 0; + let totalActive = 0; + let upcomingRenewals = 0; + const now = new Date(); + const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + + for (const sub of subs) { + // Tally by status + statusCounts[sub.status] = (statusCounts[sub.status] ?? 0) + 1; + // Tally by category + const cat = sub.category ?? "Uncategorized"; + categoryCounts[cat] = (categoryCounts[cat] ?? 0) + 1; + + if (sub.status === "active" || sub.status === "trial") { + totalActive++; + // Normalise cost to monthly + let monthly = sub.price; + if (sub.billing_cycle === "yearly") monthly = sub.price / 12; + if (sub.billing_cycle === "quarterly") monthly = sub.price / 3; + totalMonthlyCost += monthly; + totalAnnualCost += monthly * 12; + + // Check for upcoming renewals in next 7 days + if (sub.next_billing_date) { + const renewal = new Date(sub.next_billing_date); + if (renewal >= now && renewal <= sevenDaysFromNow) { + upcomingRenewals++; + } + } + } + } + + return { + totalActiveSubscriptions: totalActive, + totalMonthlyCost: Math.round(totalMonthlyCost * 100) / 100, + totalAnnualCost: Math.round(totalAnnualCost * 100) / 100, + subscriptionsByStatus: statusCounts as Record, + subscriptionsByCategory: categoryCounts, + upcomingRenewals, + }; + } catch (error: any) { + this.handleApiError(error); + } + } + + /** + * Get renewal history for a specific subscription. + * Uses the billing simulation endpoint to project past/future renewals. + */ + async getRenewalHistory(subscriptionId: string): Promise { + try { + this.log("Fetching renewal history for subscription:", subscriptionId); + // Use the billing simulation for a 365-day window to get renewal events + const response = await this.client.get("/simulation", { + params: { days: 365 }, + }); + const projections: any[] = response.data?.data?.projections ?? []; + // Filter to this subscription + const events: RenewalEvent[] = projections + .filter((p: any) => p.subscriptionId === subscriptionId) + .map((p: any) => ({ + id: `${p.subscriptionId}-${p.projectedDate}`, + subscriptionId: p.subscriptionId, + subscriptionName: p.subscriptionName, + amount: p.amount, + billingCycle: p.billingCycle, + renewedAt: p.projectedDate, + status: "success" as const, + })); + return events; + } catch (error: any) { + this.handleApiError(error); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Webhook management (client-side registry stored via API) + // ───────────────────────────────────────────────────────────────────────── + + /** + * Register a new webhook endpoint. + */ + async createWebhook(input: CreateWebhookInput): Promise { + try { + this.log("Creating webhook for URL:", input.url); + const response = await this.client.post("/webhooks", input); + const webhook: Webhook = response.data.data; + this.emit("webhook:created", webhook); + return webhook; + } catch (error: any) { + this.handleApiError(error); + } + } + + /** + * List all registered webhooks. + */ + async listWebhooks(): Promise { + try { + this.log("Listing webhooks"); + const response = await this.client.get("/webhooks"); + return response.data.data as Webhook[]; + } catch (error: any) { + this.handleApiError(error); + } + } + + /** + * Delete a webhook by ID. + */ + async deleteWebhook(id: string): Promise { + try { + this.log("Deleting webhook:", id); + await this.client.delete(`/webhooks/${id}`); + this.emit("webhook:deleted", { id }); + } catch (error: any) { + this.handleApiError(error); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Notifications + // ───────────────────────────────────────────────────────────────────────── + + /** + * Retrieve in-app notifications for the authenticated user. + * @param options.unreadOnly When true, only returns unread notifications. + */ + async getNotifications( + options?: { unreadOnly?: boolean }, + ): Promise { + try { + this.log("Fetching notifications, unreadOnly:", options?.unreadOnly); + const response = await this.client.get("/notifications", { + params: options?.unreadOnly ? { is_read: false } : {}, + }); + const raw: any[] = response.data.data ?? response.data ?? []; + // Normalise snake_case → camelCase + return raw.map((n: any) => ({ + id: n.id, + type: n.type, + title: n.title, + message: n.message, + subscriptionId: n.subscription_id ?? n.subscriptionId ?? null, + isRead: n.is_read ?? n.isRead ?? false, + createdAt: n.created_at ?? n.createdAt, + })); + } catch (error: any) { + this.handleApiError(error); + } + } + + /** + * Mark a specific notification as read. + */ + async markNotificationRead(id: string): Promise { + try { + this.log("Marking notification as read:", id); + await this.client.patch(`/notifications/${id}`, { is_read: true }); + this.emit("notification:read", { id }); + } catch (error: any) { + this.handleApiError(error); + } + } +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function hasFunctionOrStringPublicKey( + value: unknown, +): value is string | (() => string) { + return typeof value === "string" || typeof value === "function"; +} + +function validateInitConfig(config: SyncroSDKInitConfig): void { + const errors: string[] = []; + + if (!isObject(config)) { + throw new Error( + "Invalid SDK initialization config: config must be an object.", + ); + } + + // Validate apiKey is provided + if (!config.apiKey || typeof config.apiKey !== "string") { + errors.push("apiKey is required and must be a non-empty string"); + } + + // Handle both baseURL and backendApiBaseUrl for backwards compatibility + const baseUrl = config.baseURL || config.backendApiBaseUrl; + + if (baseUrl) { + if (typeof baseUrl !== "string" || baseUrl.trim().length === 0) { + errors.push("baseURL must be a non-empty string"); + } else { + try { + new URL(baseUrl); + } catch { + errors.push("baseURL must be a valid URL"); + } + } + } + + if (!config.wallet && !config.keypair) { + errors.push("Provide either a wallet object or a keypair"); + } + + if (config.wallet && !isObject(config.wallet)) { + errors.push("wallet must be an object"); + } + + if (config.keypair) { + if (!isObject(config.keypair)) { + errors.push("keypair must be an object"); + } else if (!hasFunctionOrStringPublicKey(config.keypair.publicKey)) { + errors.push( + "keypair.publicKey must be a string or a function returning a string", + ); + } + } + + if (errors.length > 0) { + throw new Error(`Invalid SDK initialization config: ${errors.join("; ")}`); + } +} + +function getSignerPublicKey( + wallet?: StellarWallet, + keypair?: StellarKeypair, +): string | undefined { + if (wallet?.publicKey) { + return typeof wallet.publicKey === "function" + ? wallet.publicKey() + : wallet.publicKey; + } + + if (keypair?.publicKey) { + return typeof keypair.publicKey === "function" + ? keypair.publicKey() + : keypair.publicKey; + } + + return undefined; +} + +/** + * Create and initialize a Syncro SDK instance + * @param config SDK configuration + * @returns Initialized SyncroSDK instance + * @throws Error if configuration is invalid + */ +export function init(config: SyncroSDKInitConfig): SyncroSDK { + validateInitConfig(config); + + // Use baseURL if provided, otherwise fall back to backendApiBaseUrl for backwards compatibility + const finalConfig: SyncroSDKConfig = { + apiKey: config.apiKey, + ...(config.baseURL !== undefined && { baseURL: config.baseURL }), + ...(config.baseURL === undefined && config.backendApiBaseUrl !== undefined && { + baseURL: config.backendApiBaseUrl, + }), + ...(config.timeout !== undefined && { timeout: config.timeout }), + ...(config.retryOptions !== undefined && { retryOptions: config.retryOptions }), + ...(config.batchConcurrency !== undefined && { + batchConcurrency: config.batchConcurrency, + }), + ...(config.enableLogging !== undefined && { enableLogging: config.enableLogging }), + ...(config.wallet !== undefined && { wallet: config.wallet }), + ...(config.keypair !== undefined && { keypair: config.keypair }), + }; + + const sdk = new SyncroSDK(finalConfig); + + const readyPayload = { + baseURL: finalConfig.baseURL, + publicKey: getSignerPublicKey(config.wallet, config.keypair), + }; + + queueMicrotask(() => { + sdk.emit("ready", readyPayload); + }); + + return sdk; +} + +export default SyncroSDK; +export type { + GiftCardEvent, + GiftCardEventType, + SyncroSDKConfig, + SyncroSDKInitConfig, + RetryOptions, + StellarWallet, + StellarKeypair, + // Subscription types + CreateSubscriptionInput, + UpdateSubscriptionInput, + SubscriptionFilters, + SubscriptionRecord, + PaginatedResult, + // Analytics types + AnalyticsSummary, + RenewalEvent, + // Webhook types + CreateWebhookInput, + Webhook, + // Notification types + AppNotification, +} from "./types.js"; +export { + SyncroError, + NotFoundError, + AuthenticationError, + RateLimitError, + ValidationError, + ConflictError, + ForbiddenError, + createApiError, +} from "./errors.js"; \ No newline at end of file