diff --git a/package-lock.json b/package-lock.json index 4d74889..23746d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,8 +23,10 @@ "morgan": "~1.10.1", "multer": "~2.0.1", "pg": "~8.20.0", - "sharp": "~0.33.5", + "sanitize-html": "~2.17.6", "stellar-sdk": "~13.3.0", + "swagger-jsdoc": "~6.3.0", + "swagger-ui-express": "~5.0.1", "zod": "~4.4.3" }, "devDependencies": { @@ -37,6 +39,9 @@ "@types/morgan": "~1.9.9", "@types/multer": "~1.4.12", "@types/node": "~25.8.0", + "@types/sanitize-html": "~2.16.1", + "@types/swagger-jsdoc": "~6.0.4", + "@types/swagger-ui-express": "~4.1.8", "eslint": "~9.18.0", "prisma": "~7.8.0", "tsx": "~4.22.0", @@ -45,813 +50,52 @@ "vitest": "~3.2.0" } }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/crc32c": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", - "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", - "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-sdk/client-s3": { - "version": "3.800.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.800.0.tgz", - "integrity": "sha512-SE33Y1kbeErd5h7KlmgWs1iJ0kKi+/t9XilI6NPIb5J5TmPKVUT5gf3ywa9ZSaq1x7LiAbICm0IPEz6k0WEBbQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha1-browser": "5.2.0", - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.799.0", - "@aws-sdk/credential-provider-node": "3.799.0", - "@aws-sdk/middleware-bucket-endpoint": "3.775.0", - "@aws-sdk/middleware-expect-continue": "3.775.0", - "@aws-sdk/middleware-flexible-checksums": "3.799.0", - "@aws-sdk/middleware-host-header": "3.775.0", - "@aws-sdk/middleware-location-constraint": "3.775.0", - "@aws-sdk/middleware-logger": "3.775.0", - "@aws-sdk/middleware-recursion-detection": "3.775.0", - "@aws-sdk/middleware-sdk-s3": "3.799.0", - "@aws-sdk/middleware-ssec": "3.775.0", - "@aws-sdk/middleware-user-agent": "3.799.0", - "@aws-sdk/region-config-resolver": "3.775.0", - "@aws-sdk/signature-v4-multi-region": "3.800.0", - "@aws-sdk/types": "3.775.0", - "@aws-sdk/util-endpoints": "3.787.0", - "@aws-sdk/util-user-agent-browser": "3.775.0", - "@aws-sdk/util-user-agent-node": "3.799.0", - "@aws-sdk/xml-builder": "3.775.0", - "@smithy/config-resolver": "^4.1.0", - "@smithy/core": "^3.3.0", - "@smithy/eventstream-serde-browser": "^4.0.2", - "@smithy/eventstream-serde-config-resolver": "^4.1.0", - "@smithy/eventstream-serde-node": "^4.0.2", - "@smithy/fetch-http-handler": "^5.0.2", - "@smithy/hash-blob-browser": "^4.0.2", - "@smithy/hash-node": "^4.0.2", - "@smithy/hash-stream-node": "^4.0.2", - "@smithy/invalid-dependency": "^4.0.2", - "@smithy/md5-js": "^4.0.2", - "@smithy/middleware-content-length": "^4.0.2", - "@smithy/middleware-endpoint": "^4.1.1", - "@smithy/middleware-retry": "^4.1.1", - "@smithy/middleware-serde": "^4.0.3", - "@smithy/middleware-stack": "^4.0.2", - "@smithy/node-config-provider": "^4.0.2", - "@smithy/node-http-handler": "^4.0.4", - "@smithy/protocol-http": "^5.1.0", - "@smithy/smithy-client": "^4.2.1", - "@smithy/types": "^4.2.0", - "@smithy/url-parser": "^4.0.2", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.9", - "@smithy/util-defaults-mode-node": "^4.0.9", - "@smithy/util-endpoints": "^3.0.2", - "@smithy/util-middleware": "^4.0.2", - "@smithy/util-retry": "^4.0.2", - "@smithy/util-stream": "^4.2.0", - "@smithy/util-utf8": "^4.0.0", - "@smithy/util-waiter": "^4.0.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-sso": { - "version": "3.799.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.799.0.tgz", - "integrity": "sha512-/i/LG7AiWPmPxKCA2jnR2zaf7B3HYSTbxaZI21ElIz9wASlNAsKr8CnLY7qb50kOyXiNfQ834S5Q3Gl8dX9o3Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.799.0", - "@aws-sdk/middleware-host-header": "3.775.0", - "@aws-sdk/middleware-logger": "3.775.0", - "@aws-sdk/middleware-recursion-detection": "3.775.0", - "@aws-sdk/middleware-user-agent": "3.799.0", - "@aws-sdk/region-config-resolver": "3.775.0", - "@aws-sdk/types": "3.775.0", - "@aws-sdk/util-endpoints": "3.787.0", - "@aws-sdk/util-user-agent-browser": "3.775.0", - "@aws-sdk/util-user-agent-node": "3.799.0", - "@smithy/config-resolver": "^4.1.0", - "@smithy/core": "^3.3.0", - "@smithy/fetch-http-handler": "^5.0.2", - "@smithy/hash-node": "^4.0.2", - "@smithy/invalid-dependency": "^4.0.2", - "@smithy/middleware-content-length": "^4.0.2", - "@smithy/middleware-endpoint": "^4.1.1", - "@smithy/middleware-retry": "^4.1.1", - "@smithy/middleware-serde": "^4.0.3", - "@smithy/middleware-stack": "^4.0.2", - "@smithy/node-config-provider": "^4.0.2", - "@smithy/node-http-handler": "^4.0.4", - "@smithy/protocol-http": "^5.1.0", - "@smithy/smithy-client": "^4.2.1", - "@smithy/types": "^4.2.0", - "@smithy/url-parser": "^4.0.2", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.9", - "@smithy/util-defaults-mode-node": "^4.0.9", - "@smithy/util-endpoints": "^3.0.2", - "@smithy/util-middleware": "^4.0.2", - "@smithy/util-retry": "^4.0.2", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.799.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.799.0.tgz", - "integrity": "sha512-hkKF3Zpc6+H8GI1rlttYVRh9uEE77cqAzLmLpY3iu7sql8cZgPERRBfaFct8p1SaDyrksLNiboD1vKW58mbsYg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.775.0", - "@smithy/core": "^3.3.0", - "@smithy/node-config-provider": "^4.0.2", - "@smithy/property-provider": "^4.0.2", - "@smithy/protocol-http": "^5.1.0", - "@smithy/signature-v4": "^5.1.0", - "@smithy/smithy-client": "^4.2.1", - "@smithy/types": "^4.2.0", - "@smithy/util-middleware": "^4.0.2", - "fast-xml-parser": "4.4.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.799.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.799.0.tgz", - "integrity": "sha512-vT/SSWtbUIOW/U21qgEySmmO44SFWIA7WeQPX1OrI8WJ5n7OEI23JWLHjLvHTkYmuZK6z1rPcv7HzRgmuGRibA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.799.0", - "@aws-sdk/types": "3.775.0", - "@smithy/property-provider": "^4.0.2", - "@smithy/types": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.799.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.799.0.tgz", - "integrity": "sha512-2CjBpOWmhaPAExOgHnIB5nOkS5ef+mfRlJ1JC4nsnjAx0nrK4tk0XRE0LYz11P3+ue+a86cU8WTmBo+qjnGxPQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.799.0", - "@aws-sdk/types": "3.775.0", - "@smithy/fetch-http-handler": "^5.0.2", - "@smithy/node-http-handler": "^4.0.4", - "@smithy/property-provider": "^4.0.2", - "@smithy/protocol-http": "^5.1.0", - "@smithy/smithy-client": "^4.2.1", - "@smithy/types": "^4.2.0", - "@smithy/util-stream": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.799.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.799.0.tgz", - "integrity": "sha512-M9ubILFxerqw4QJwk83MnjtZyoA2eNCiea5V+PzZeHlwk2PON/EnawKqy65x9/hMHGoSvvNuby7iMAmPptu7yw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.799.0", - "@aws-sdk/credential-provider-env": "3.799.0", - "@aws-sdk/credential-provider-http": "3.799.0", - "@aws-sdk/credential-provider-process": "3.799.0", - "@aws-sdk/credential-provider-sso": "3.799.0", - "@aws-sdk/credential-provider-web-identity": "3.799.0", - "@aws-sdk/nested-clients": "3.799.0", - "@aws-sdk/types": "3.775.0", - "@smithy/credential-provider-imds": "^4.0.2", - "@smithy/property-provider": "^4.0.2", - "@smithy/shared-ini-file-loader": "^4.0.2", - "@smithy/types": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.799.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.799.0.tgz", - "integrity": "sha512-nd9fSJc0wUlgKUkIr2ldJhcIIrzJFS29AGZoyY22J3xih63nNDv61eTGVMsDZzHlV21XzMlPEljTR7axiimckg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "3.799.0", - "@aws-sdk/credential-provider-http": "3.799.0", - "@aws-sdk/credential-provider-ini": "3.799.0", - "@aws-sdk/credential-provider-process": "3.799.0", - "@aws-sdk/credential-provider-sso": "3.799.0", - "@aws-sdk/credential-provider-web-identity": "3.799.0", - "@aws-sdk/types": "3.775.0", - "@smithy/credential-provider-imds": "^4.0.2", - "@smithy/property-provider": "^4.0.2", - "@smithy/shared-ini-file-loader": "^4.0.2", - "@smithy/types": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.799.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.799.0.tgz", - "integrity": "sha512-g8jmNs2k98WNHMYcea1YKA+7ao2Ma4w0P42Dz4YpcI155pQHxHx25RwbOG+rsAKuo3bKwkW53HVE/ZTKhcWFgw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.799.0", - "@aws-sdk/types": "3.775.0", - "@smithy/property-provider": "^4.0.2", - "@smithy/shared-ini-file-loader": "^4.0.2", - "@smithy/types": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.799.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.799.0.tgz", - "integrity": "sha512-lQv27QkNU9FJFZqEf5DIEN3uXEN409Iaym9WJzhOouGtxvTIAWiD23OYh1u8PvBdrordJGS2YddfQvhcmq9akw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/client-sso": "3.799.0", - "@aws-sdk/core": "3.799.0", - "@aws-sdk/token-providers": "3.799.0", - "@aws-sdk/types": "3.775.0", - "@smithy/property-provider": "^4.0.2", - "@smithy/shared-ini-file-loader": "^4.0.2", - "@smithy/types": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.799.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.799.0.tgz", - "integrity": "sha512-8k1i9ut+BEg0QZ+I6UQMxGNR1T8paLmAOAZXU+nLQR0lcxS6lr8v+dqofgzQPuHLBkWNCr1Av1IKeL3bJjgU7g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.799.0", - "@aws-sdk/nested-clients": "3.799.0", - "@aws-sdk/types": "3.775.0", - "@smithy/property-provider": "^4.0.2", - "@smithy/types": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-bucket-endpoint": { - "version": "3.775.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.775.0.tgz", - "integrity": "sha512-qogMIpVChDYr4xiUNC19/RDSw/sKoHkAhouS6Skxiy6s27HBhow1L3Z1qVYXuBmOZGSWPU0xiyZCvOyWrv9s+Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.775.0", - "@aws-sdk/util-arn-parser": "3.723.0", - "@smithy/node-config-provider": "^4.0.2", - "@smithy/protocol-http": "^5.1.0", - "@smithy/types": "^4.2.0", - "@smithy/util-config-provider": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-expect-continue": { - "version": "3.775.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.775.0.tgz", - "integrity": "sha512-Apd3owkIeUW5dnk3au9np2IdW2N0zc9NjTjHiH+Mx3zqwSrc+m+ANgJVgk9mnQjMzU/vb7VuxJ0eqdEbp5gYsg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.775.0", - "@smithy/protocol-http": "^5.1.0", - "@smithy/types": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.799.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.799.0.tgz", - "integrity": "sha512-vBIAdDl2neaFiUMxyr7dAtX7m9Iw5c0bz7OirD0JGW0nYn0mBcqKpFZEU75ewA5p2+Cm7RQDdt6099ne3gj0WA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@aws-crypto/crc32c": "5.2.0", - "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "3.799.0", - "@aws-sdk/types": "3.775.0", - "@smithy/is-array-buffer": "^4.0.0", - "@smithy/node-config-provider": "^4.0.2", - "@smithy/protocol-http": "^5.1.0", - "@smithy/types": "^4.2.0", - "@smithy/util-middleware": "^4.0.2", - "@smithy/util-stream": "^4.2.0", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.775.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.775.0.tgz", - "integrity": "sha512-tkSegM0Z6WMXpLB8oPys/d+umYIocvO298mGvcMCncpRl77L9XkvSLJIFzaHes+o7djAgIduYw8wKIMStFss2w==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.775.0", - "@smithy/protocol-http": "^5.1.0", - "@smithy/types": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-location-constraint": { - "version": "3.775.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.775.0.tgz", - "integrity": "sha512-8TMXEHZXZTFTckQLyBT5aEI8fX11HZcwZseRifvBKKpj0RZDk4F0EEYGxeNSPpUQ7n+PRWyfAEnnZNRdAj/1NQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.775.0", - "@smithy/types": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.775.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.775.0.tgz", - "integrity": "sha512-FaxO1xom4MAoUJsldmR92nT1G6uZxTdNYOFYtdHfd6N2wcNaTuxgjIvqzg5y7QIH9kn58XX/dzf1iTjgqUStZw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.775.0", - "@smithy/types": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.775.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.775.0.tgz", - "integrity": "sha512-GLCzC8D0A0YDG5u3F5U03Vb9j5tcOEFhr8oc6PDk0k0vm5VwtZOE6LvK7hcCSoAB4HXyOUM0sQuXrbaAh9OwXA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.775.0", - "@smithy/protocol-http": "^5.1.0", - "@smithy/types": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.799.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.799.0.tgz", - "integrity": "sha512-Zwdge5NArgcJwPuGZwgfXY6XXkWEBmMS9dqu5g3DcfHmZUuSjQUqmOsDdSZlE3RFHrDAEbuGQlrFUE8zuwdKQA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.799.0", - "@aws-sdk/types": "3.775.0", - "@aws-sdk/util-arn-parser": "3.723.0", - "@smithy/core": "^3.3.0", - "@smithy/node-config-provider": "^4.0.2", - "@smithy/protocol-http": "^5.1.0", - "@smithy/signature-v4": "^5.1.0", - "@smithy/smithy-client": "^4.2.1", - "@smithy/types": "^4.2.0", - "@smithy/util-config-provider": "^4.0.0", - "@smithy/util-middleware": "^4.0.2", - "@smithy/util-stream": "^4.2.0", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-ssec": { - "version": "3.775.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.775.0.tgz", - "integrity": "sha512-Iw1RHD8vfAWWPzBBIKaojO4GAvQkHOYIpKdAfis/EUSUmSa79QsnXnRqsdcE0mCB0Ylj23yi+ah4/0wh9FsekA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.775.0", - "@smithy/types": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.799.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.799.0.tgz", - "integrity": "sha512-TropQZanbOTxa+p+Nl4fWkzlRhgFwDfW+Wb6TR3jZN7IXHNlPpgGFpdrgvBExhW/RBhqr+94OsR8Ou58lp3hhA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.799.0", - "@aws-sdk/types": "3.775.0", - "@aws-sdk/util-endpoints": "3.787.0", - "@smithy/core": "^3.3.0", - "@smithy/protocol-http": "^5.1.0", - "@smithy/types": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.799.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.799.0.tgz", - "integrity": "sha512-zILlWh7asrcQG9JYMYgnvEQBfwmWKfED0yWCf3UNAmQcfS9wkCAWCgicNy/y5KvNvEYnHidsU117STtyuUNG5g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.799.0", - "@aws-sdk/middleware-host-header": "3.775.0", - "@aws-sdk/middleware-logger": "3.775.0", - "@aws-sdk/middleware-recursion-detection": "3.775.0", - "@aws-sdk/middleware-user-agent": "3.799.0", - "@aws-sdk/region-config-resolver": "3.775.0", - "@aws-sdk/types": "3.775.0", - "@aws-sdk/util-endpoints": "3.787.0", - "@aws-sdk/util-user-agent-browser": "3.775.0", - "@aws-sdk/util-user-agent-node": "3.799.0", - "@smithy/config-resolver": "^4.1.0", - "@smithy/core": "^3.3.0", - "@smithy/fetch-http-handler": "^5.0.2", - "@smithy/hash-node": "^4.0.2", - "@smithy/invalid-dependency": "^4.0.2", - "@smithy/middleware-content-length": "^4.0.2", - "@smithy/middleware-endpoint": "^4.1.1", - "@smithy/middleware-retry": "^4.1.1", - "@smithy/middleware-serde": "^4.0.3", - "@smithy/middleware-stack": "^4.0.2", - "@smithy/node-config-provider": "^4.0.2", - "@smithy/node-http-handler": "^4.0.4", - "@smithy/protocol-http": "^5.1.0", - "@smithy/smithy-client": "^4.2.1", - "@smithy/types": "^4.2.0", - "@smithy/url-parser": "^4.0.2", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.9", - "@smithy/util-defaults-mode-node": "^4.0.9", - "@smithy/util-endpoints": "^3.0.2", - "@smithy/util-middleware": "^4.0.2", - "@smithy/util-retry": "^4.0.2", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.775.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.775.0.tgz", - "integrity": "sha512-40iH3LJjrQS3LKUJAl7Wj0bln7RFPEvUYKFxtP8a+oKFDO0F65F52xZxIJbPn6sHkxWDAnZlGgdjZXM3p2g5wQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.775.0", - "@smithy/node-config-provider": "^4.0.2", - "@smithy/types": "^4.2.0", - "@smithy/util-config-provider": "^4.0.0", - "@smithy/util-middleware": "^4.0.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/s3-request-presigner": { - "version": "3.800.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.800.0.tgz", - "integrity": "sha512-WtdEh9gIf2kIKuUNX8+At4eGdzEhmkEqVJpMed0j9srqNm9uT/popHsOSKM2hycr89CMIZVKARTadYr/93tuqA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/signature-v4-multi-region": "3.800.0", - "@aws-sdk/types": "3.775.0", - "@aws-sdk/util-format-url": "3.775.0", - "@smithy/middleware-endpoint": "^4.1.1", - "@smithy/protocol-http": "^5.1.0", - "@smithy/smithy-client": "^4.2.1", - "@smithy/types": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.800.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.800.0.tgz", - "integrity": "sha512-c71wZuiSUHNFCvcuqOv3jbqP+NquB2YKN4qX90OwYXEqUKn8F8fKJPpjjHjz1eK6qWKtECR4V/NTno2P70Yz/Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-sdk-s3": "3.799.0", - "@aws-sdk/types": "3.775.0", - "@smithy/protocol-http": "^5.1.0", - "@smithy/signature-v4": "^5.1.0", - "@smithy/types": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.799.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.799.0.tgz", - "integrity": "sha512-/8iDjnsJs/D8AhGbDAmdF5oSHzE4jsDsM2RIIxmBAKTZXkaaclQBNX9CmAqLKQmO3IUMZsDH2KENHLVAk/N/mw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/nested-clients": "3.799.0", - "@aws-sdk/types": "3.775.0", - "@smithy/property-provider": "^4.0.2", - "@smithy/shared-ini-file-loader": "^4.0.2", - "@smithy/types": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.775.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.775.0.tgz", - "integrity": "sha512-ZoGKwa4C9fC9Av6bdfqcW6Ix5ot05F/S4VxWR2nHuMv7hzfmAjTOcUiWT7UR4hM/U0whf84VhDtXN/DWAk52KA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.723.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.723.0.tgz", - "integrity": "sha512-ZhEfvUwNliOQROcAk34WJWVYTlTa4694kSVhDSjW6lE1bMataPnIN8A0ycukEzBXmd8ZSoBcQLn6lKGl7XIJ5w==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.787.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.787.0.tgz", - "integrity": "sha512-fd3zkiOkwnbdbN0Xp9TsP5SWrmv0SpT70YEdbb8wAj2DWQwiCmFszaSs+YCvhoCdmlR3Wl9Spu0pGpSAGKeYvQ==", - "license": "Apache-2.0", + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz", + "integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==", + "license": "MIT", "dependencies": { - "@aws-sdk/types": "3.775.0", - "@smithy/types": "^4.2.0", - "@smithy/util-endpoints": "^3.0.2", - "tslib": "^2.6.2" + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-format-url": { - "version": "3.775.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.775.0.tgz", - "integrity": "sha512-Nw4nBeyCbWixoGh8NcVpa/i8McMA6RXJIjQFyloJLaPr7CPquz7ZbSl0MUWMFVwP/VHaJ7B+lNN3Qz1iFCEP/Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.775.0", - "@smithy/querystring-builder": "^4.0.2", - "@smithy/types": "^4.2.0", - "tslib": "^2.6.2" + "node": ">= 16" }, - "engines": { - "node": ">=18.0.0" + "funding": { + "url": "https://github.com/sponsors/philsturgeon" } }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.8.tgz", - "integrity": "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, + "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": ">=20.0.0" + "node": ">=10" } }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.775.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.775.0.tgz", - "integrity": "sha512-txw2wkiJmZKVdDbscK7VBK+u+TJnRtlUjRTLei+elZg2ADhpQxfVAQl436FUeIv6AhB/oRHW6/K/EAGXUSWi0A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.775.0", - "@smithy/types": "^4.2.0", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - } + "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/@aws-sdk/util-user-agent-node": { - "version": "3.799.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.799.0.tgz", - "integrity": "sha512-iXBk38RbIWPF5Nq9O4AnktORAzXovSVqWYClvS1qbE7ILsnTLJbagU9HlU25O2iV5COVh1qZkwuP5NHQ2yTEyw==", - "license": "Apache-2.0", + "node_modules/@apidevtools/swagger-parser": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz", + "integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==", + "license": "MIT", "dependencies": { - "@aws-sdk/middleware-user-agent": "3.799.0", - "@aws-sdk/types": "3.775.0", - "@smithy/node-config-provider": "^4.0.2", - "@smithy/types": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@apidevtools/json-schema-ref-parser": "14.0.1", + "@apidevtools/openapi-schemas": "^2.1.0", + "@apidevtools/swagger-methods": "^3.0.2", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "call-me-maybe": "^1.0.2" }, "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } - } - }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.775.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.775.0.tgz", - "integrity": "sha512-b9NGO6FKJeLGYnV7Z1yvcP1TNU4dkD5jNsLWOF1/sygZoASaQhNOlaiJ/1OH331YQ1R1oWk38nBb0frsYkDsOQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "openapi-types": ">=7" } }, "node_modules/@electric-sql/pglite": { @@ -3074,10 +2318,33 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/types": { - "version": "4.16.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.0.tgz", - "integrity": "sha512-aVUabzlBBmY0PfvVgLKQSOGFIL5/7R54JE3uD9a5Ay/jSED61SkuAcCYENNXJzYUvJ1NPrWO0P+rAXHCkbBUKw==", + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@prisma/client": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", + "integrity": "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -3791,6 +3058,13 @@ "win32" ] }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3954,7 +3228,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/jsonwebtoken": { @@ -4055,10 +3328,125 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/runner": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", - "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "node_modules/@types/sanitize-html": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz", + "integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "htmlparser2": "^10.1" + } + }, + "node_modules/@types/sanitize-html/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/@types/sanitize-html/node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@types/sanitize-html/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/@types/sanitize-html/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/@types/sanitize-html/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/@types/sanitize-html/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@types/sanitize-html/node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4085,6 +3473,24 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@types/swagger-jsdoc": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz", + "integrity": "sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@vitest/expect": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", @@ -4249,10 +3655,9 @@ } }, "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -4265,6 +3670,20 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -4285,7 +3704,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/assertion-error": { @@ -4677,6 +4095,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", @@ -4814,6 +4238,15 @@ "node": ">= 0.8" } }, + "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/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4914,7 +4347,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "devOptional": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4933,6 +4365,12 @@ "license": "MIT", "peer": true }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4967,6 +4405,15 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deepmerge-ts": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", @@ -5036,6 +4483,85 @@ "devOptional": true, "license": "MIT" }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-serializer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -5107,6 +4633,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -5224,7 +4762,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -5428,7 +4965,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" @@ -5566,7 +5102,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "devOptional": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -5584,10 +5119,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", - "devOptional": true, + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -5760,7 +5294,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "devOptional": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -5916,17 +5449,64 @@ "giget": "dist/cli.mjs" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "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": "BlueOak-1.0.0", "dependencies": { - "is-glob": "^4.0.3" + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" }, "engines": { - "node": ">=10.13.0" + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/globals": { @@ -6055,6 +5635,28 @@ "node": ">=16.9.0" } }, + "node_modules/htmlparser2": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz", + "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "domutils": "^4.0.2", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -6233,6 +5835,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -6283,9 +5894,23 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true, "license": "ISC" }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -6307,7 +5932,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, "funding": [ { "type": "github", @@ -6407,6 +6031,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/launder": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", + "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", + "license": "MIT", + "dependencies": { + "dayjs": "^1.11.7" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -6480,6 +6113,12 @@ "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", @@ -6500,6 +6139,15 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/lru.min": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", @@ -6763,7 +6411,6 @@ "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", - "dev": true, "funding": [ { "type": "github", @@ -6852,6 +6499,13 @@ "wrappy": "1" } }, + "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", @@ -6902,6 +6556,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "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==", + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6915,6 +6575,12 @@ "node": ">=6" } }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "license": "MIT" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -6938,12 +6604,27 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -7071,7 +6752,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -7112,7 +6792,6 @@ "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -7446,7 +7125,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7559,6 +7237,24 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sanitize-html": { + "version": "2.17.6", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.6.tgz", + "integrity": "sha512-M4bo9tfv1yfhQZZKkc6dL07ALrGJtfvNOuhX3hU9AVPR/uPQ+nKOJBqTYc7LfMQblTW04mtSWDJWEyLvygJsLA==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^12.0.0", + "is-plain-object": "^5.0.0", + "launder": "^1.7.1", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -7716,7 +7412,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "devOptional": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -7729,7 +7424,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -7818,7 +7512,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "devOptional": true, "license": "ISC", "engines": { "node": ">=14" @@ -7850,7 +7543,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -8087,6 +7779,59 @@ "node": ">=8" } }, + "node_modules/swagger-jsdoc": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.3.0.tgz", + "integrity": "sha512-I+iQjVGV3t28pOkQUJv2MncthvOtkEactOn8R76SvSYhxgtIn7FoqfDHwQaN+GBnQdXQLrhgDXseKitmJcHMsA==", + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "^12.1.0", + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "11.1.0", + "lodash.mergewith": "^4.6.2", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/swagger-jsdoc/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/swagger-ui-dist": { + "version": "5.32.8", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz", + "integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -8616,7 +8361,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -8691,6 +8435,24 @@ "node": ">=0.4" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 6a52829..ab5b551 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,10 @@ "morgan": "~1.10.1", "multer": "~2.0.1", "pg": "~8.20.0", - "sharp": "~0.33.5", + "sanitize-html": "~2.17.6", "stellar-sdk": "~13.3.0", + "swagger-jsdoc": "~6.3.0", + "swagger-ui-express": "~5.0.1", "zod": "~4.4.3" }, "devDependencies": { @@ -51,6 +53,9 @@ "@types/morgan": "~1.9.9", "@types/multer": "~1.4.12", "@types/node": "~25.8.0", + "@types/sanitize-html": "~2.16.1", + "@types/swagger-jsdoc": "~6.0.4", + "@types/swagger-ui-express": "~4.1.8", "eslint": "~9.18.0", "prisma": "~7.8.0", "tsx": "~4.22.0", diff --git a/src/__tests__/fixtures/index.ts b/src/__tests__/fixtures/index.ts new file mode 100644 index 0000000..9a36dd9 --- /dev/null +++ b/src/__tests__/fixtures/index.ts @@ -0,0 +1,87 @@ +export const mockUser = { + id: "clx1234567890", + fullname: "John Doe", + email: "john@example.com", + password: "$2a$12$hashedpassword", + walletAddress: "GCKFBEIYVYQV6FYNUWZV6MO7VVI7RMUO6ESLO45S73JQ27LHJKR2", + profileImage: null, + country: "US", + role: "FREELANCER" as const, + isVerified: true, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), +}; + +export const mockClient = { + ...mockUser, + id: "clx1234567891", + fullname: "Jane Smith", + email: "jane@example.com", + role: "CLIENT" as const, +}; + +export const mockAdmin = { + ...mockUser, + id: "clx1234567892", + fullname: "Admin User", + email: "admin@example.com", + role: "ADMIN" as const, +}; + +export const mockInvoice = { + id: "clx_inv_001", + creatorId: "clx1234567890", + recipientId: "clx1234567891", + title: "Web Development Services", + description: "Frontend development for e-commerce site", + amount: 1500, + currency: "USDC", + status: "DRAFT" as const, + dueDate: new Date("2024-02-01"), + paidAt: null, + txHash: null, + contractId: null, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + creator: { id: "clx1234567890", fullname: "John Doe", email: "john@example.com" }, + recipient: { id: "clx1234567891", fullname: "Jane Smith", email: "jane@example.com" }, + escrow: null, +}; + +export const mockEscrow = { + id: "clx_esc_001", + invoiceId: "clx_inv_001", + clientId: "clx1234567891", + freelancerId: "clx1234567890", + contractId: "contract_mock_123", + txHash: null, + amount: 1500, + currency: "USDC", + status: "PENDING" as const, + fundedAt: null, + releasedAt: null, + refundedAt: null, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + invoice: { id: "clx_inv_001", title: "Web Development Services", amount: 1500, status: "IN_ESCROW" as const }, + client: { id: "clx1234567891", fullname: "Jane Smith", email: "jane@example.com" }, + freelancer: { id: "clx1234567890", fullname: "John Doe", email: "john@example.com" }, + payments: [], +}; + +export const mockPayment = { + id: "clx_pay_001", + userId: "clx1234567891", + escrowId: "clx_esc_001", + amount: 1500, + currency: "USDC", + txHash: "tx_mock_fund", + status: "SUCCESS" as const, + description: "Funded escrow for invoice", + createdAt: new Date("2024-01-01"), +}; + +export const mockTokens = { + accessToken: "eyJhbGciOiJIUzI1NiJ9.mock.access.token", + refreshToken: "eyJhbGciOiJIUzI1NiJ9.mock.refresh.token", +}; diff --git a/src/__tests__/integration/auth.test.ts b/src/__tests__/integration/auth.test.ts new file mode 100644 index 0000000..2fc29f4 --- /dev/null +++ b/src/__tests__/integration/auth.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Request, Response } from "express"; +import bcrypt from "bcryptjs"; +import { prisma } from "../../config/prisma.js"; +import * as authController from "../../controllers/auth.controller.js"; +import { mockUser } from "../fixtures/index.js"; + +vi.mock("jsonwebtoken", () => ({ + default: { + sign: vi.fn().mockReturnValue("mock-jwt-token"), + verify: vi.fn().mockReturnValue({ userId: "clx1234567890", email: "john@example.com", role: "FREELANCER" }), + TokenExpiredError: class TokenExpiredError extends Error {}, + JsonWebTokenError: class JsonWebTokenError extends Error {}, + }, +})); + +function mockReq(body: Record = {}, cookies: Record = {}): Request { + return { + body, + cookies, + headers: {}, + params: {}, + query: {}, + ip: "127.0.0.1", + socket: { remoteAddress: "127.0.0.1" }, + } as unknown as Request; +} + +function mockRes(): Response { + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + cookie: vi.fn().mockReturnThis(), + clearCookie: vi.fn().mockReturnThis(), + } as unknown as Response; + return res; +} + +describe("Auth Controller", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("register", () => { + it("should register a new user", async () => { + const hashedPassword = await bcrypt.hash("password123", 12); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.create).mockResolvedValue({ + ...mockUser, + password: hashedPassword, + }); + + const req = mockReq({ + fullname: "John Doe", + email: "john@example.com", + password: "password123", + role: "FREELANCER", + }); + const res = mockRes(); + + await authController.register(req, res); + + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + user: expect.objectContaining({ email: "john@example.com" }), + }), + }) + ); + }); + + it("should throw ConflictError for existing email", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as never); + + const req = mockReq({ + fullname: "John Doe", + email: "john@example.com", + password: "password123", + role: "FREELANCER", + }); + const res = mockRes(); + + await expect(authController.register(req, res)).rejects.toThrow("Email already registered"); + }); + }); + + describe("login", () => { + it("should login with valid credentials", async () => { + const hashedPassword = await bcrypt.hash("password123", 12); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + ...mockUser, + password: hashedPassword, + }); + + const req = mockReq({ email: "john@example.com", password: "password123" }); + const res = mockRes(); + + await authController.login(req, res); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + accessToken: expect.any(String), + }), + }) + ); + }); + + it("should throw UnauthorizedError for invalid email", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + + const req = mockReq({ email: "nonexistent@example.com", password: "password123" }); + const res = mockRes(); + + await expect(authController.login(req, res)).rejects.toThrow("Invalid email or password"); + }); + + it("should throw UnauthorizedError for wrong password", async () => { + const hashedPassword = await bcrypt.hash("wrongpassword", 12); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + ...mockUser, + password: hashedPassword, + }); + + const req = mockReq({ email: "john@example.com", password: "password123" }); + const res = mockRes(); + + await expect(authController.login(req, res)).rejects.toThrow("Invalid email or password"); + }); + }); + + describe("logout", () => { + it("should clear refresh token cookie", () => { + const req = mockReq(); + const res = mockRes(); + + authController.logout(req, res); + + expect(res.clearCookie).toHaveBeenCalledWith("refreshToken", expect.any(Object)); + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: { message: "Logged out successfully" }, + }); + }); + }); + + describe("getMe", () => { + it("should return current user profile", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as never); + + const req = mockReq(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (req as any).user = { userId: "clx1234567890" }; + const res = mockRes(); + + await authController.getMe(req, res); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + user: expect.objectContaining({ email: "john@example.com" }), + }), + }) + ); + }); + + it("should throw NotFoundError for non-existent user", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + + const req = mockReq(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (req as any).user = { userId: "nonexistent" }; + const res = mockRes(); + + await expect(authController.getMe(req, res)).rejects.toThrow("User not found"); + }); + }); +}); diff --git a/src/__tests__/integration/escrow.test.ts b/src/__tests__/integration/escrow.test.ts new file mode 100644 index 0000000..b1ec963 --- /dev/null +++ b/src/__tests__/integration/escrow.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Request, Response } from "express"; +import { prisma } from "../../config/prisma.js"; +import * as stellarService from "../../services/stellar.service.js"; +import * as escrowController from "../../controllers/escrow.controller.js"; +import { mockInvoice, mockEscrow } from "../fixtures/index.js"; + +function mockReq(body: Record = {}, params: Record = {}): Request { + return { + body, + params, + query: {}, + headers: {}, + ip: "127.0.0.1", + socket: { remoteAddress: "127.0.0.1" }, + } as unknown as Request; +} + +function mockRes(): Response { + return { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } as unknown as Response; +} + +function withUser(req: Request, userId: string, role: string): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (req as any).user = { userId, role }; +} + +describe("Escrow Controller", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("createEscrow", () => { + it("should create escrow as client", async () => { + vi.mocked(prisma.invoice.findUnique).mockResolvedValue({ + ...mockInvoice, + status: "PENDING", + } as never); + vi.mocked(prisma.escrow.findUnique).mockResolvedValue(null); + vi.mocked(stellarService.createEscrowContract).mockResolvedValue({ + success: true, + txHash: "tx_123", + contractId: "contract_123", + }); + vi.mocked(prisma.escrow.create).mockResolvedValue(mockEscrow as never); + vi.mocked(prisma.invoice.update).mockResolvedValue({} as never); + + const req = mockReq({ invoiceId: "clx_inv_001", contractId: "contract_123" }); + withUser(req, "clx1234567891", "CLIENT"); + const res = mockRes(); + + await escrowController.createEscrow(req, res); + + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ escrow: expect.any(Object) }), + }) + ); + }); + + it("should reject escrow creation by freelancer", async () => { + const req = mockReq({ invoiceId: "clx_inv_001", contractId: "contract_123" }); + withUser(req, "clx1234567890", "FREELANCER"); + const res = mockRes(); + + await expect(escrowController.createEscrow(req, res)).rejects.toThrow("Only clients can create escrows"); + }); + + it("should reject duplicate escrow", async () => { + vi.mocked(prisma.invoice.findUnique).mockResolvedValue({ + ...mockInvoice, + status: "PENDING", + } as never); + vi.mocked(prisma.escrow.findUnique).mockResolvedValue(mockEscrow as never); + + const req = mockReq({ invoiceId: "clx_inv_001", contractId: "contract_123" }); + withUser(req, "clx1234567891", "CLIENT"); + const res = mockRes(); + + await expect(escrowController.createEscrow(req, res)).rejects.toThrow("Escrow already exists for this invoice"); + }); + }); + + describe("fundEscrow", () => { + it("should fund escrow as client", async () => { + vi.mocked(prisma.escrow.findUnique).mockResolvedValue(mockEscrow as never); + vi.mocked(stellarService.fundEscrow).mockResolvedValue({ + success: true, + txHash: "tx_fund_123", + }); + vi.mocked(prisma.escrow.update).mockResolvedValue({ + ...mockEscrow, + status: "FUNDED", + fundedAt: new Date(), + } as never); + vi.mocked(prisma.payment.create).mockResolvedValue({} as never); + vi.mocked(prisma.invoice.update).mockResolvedValue({} as never); + + const req = mockReq({ txHash: "tx_fund_123" }, { id: "clx_esc_001" }); + withUser(req, "clx1234567891", "CLIENT"); + const res = mockRes(); + + await escrowController.fundEscrow(req, res); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ escrow: expect.any(Object) }), + }) + ); + }); + + it("should reject funding by non-client", async () => { + vi.mocked(prisma.escrow.findUnique).mockResolvedValue(mockEscrow as never); + + const req = mockReq({ txHash: "tx_fund_123" }, { id: "clx_esc_001" }); + withUser(req, "clx1234567890", "FREELANCER"); + const res = mockRes(); + + await expect(escrowController.fundEscrow(req, res)).rejects.toThrow("Only the client can fund this escrow"); + }); + }); + + describe("releaseEscrow", () => { + it("should release escrow as freelancer", async () => { + vi.mocked(prisma.escrow.findUnique).mockResolvedValue({ + ...mockEscrow, + status: "FUNDED", + } as never); + vi.mocked(stellarService.releaseEscrowFunds).mockResolvedValue({ + success: true, + txHash: "tx_release_123", + }); + vi.mocked(prisma.escrow.update).mockResolvedValue({ + ...mockEscrow, + status: "RELEASED", + releasedAt: new Date(), + } as never); + vi.mocked(prisma.payment.create).mockResolvedValue({} as never); + vi.mocked(prisma.invoice.update).mockResolvedValue({} as never); + + const req = mockReq({ txHash: "tx_release_123" }, { id: "clx_esc_001" }); + withUser(req, "clx1234567890", "FREELANCER"); + const res = mockRes(); + + await escrowController.releaseEscrow(req, res); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ escrow: expect.any(Object) }), + }) + ); + }); + + it("should reject release by client", async () => { + vi.mocked(prisma.escrow.findUnique).mockResolvedValue({ + ...mockEscrow, + status: "FUNDED", + } as never); + + const req = mockReq({ txHash: "tx_release_123" }, { id: "clx_esc_001" }); + withUser(req, "clx1234567891", "CLIENT"); + const res = mockRes(); + + await expect(escrowController.releaseEscrow(req, res)).rejects.toThrow("Only the freelancer or admin can release escrow"); + }); + }); + + describe("refundEscrow", () => { + it("should refund escrow as admin", async () => { + vi.mocked(prisma.escrow.findUnique).mockResolvedValue({ + ...mockEscrow, + status: "FUNDED", + } as never); + vi.mocked(stellarService.refundEscrowFunds).mockResolvedValue({ + success: true, + txHash: "tx_refund_123", + }); + vi.mocked(prisma.escrow.update).mockResolvedValue({ + ...mockEscrow, + status: "REFUNDED", + refundedAt: new Date(), + } as never); + vi.mocked(prisma.payment.create).mockResolvedValue({} as never); + vi.mocked(prisma.invoice.update).mockResolvedValue({} as never); + + const req = mockReq({ txHash: "tx_refund_123" }, { id: "clx_esc_001" }); + withUser(req, "clx_admin", "ADMIN"); + const res = mockRes(); + + await escrowController.refundEscrow(req, res); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ escrow: expect.any(Object) }), + }) + ); + }); + + it("should reject refund by non-admin", async () => { + vi.mocked(prisma.escrow.findUnique).mockResolvedValue({ + ...mockEscrow, + status: "FUNDED", + } as never); + + const req = mockReq({ txHash: "tx_refund_123" }, { id: "clx_esc_001" }); + withUser(req, "clx1234567891", "CLIENT"); + const res = mockRes(); + + await expect(escrowController.refundEscrow(req, res)).rejects.toThrow("Only admins can refund escrow"); + }); + }); + + describe("getEscrow", () => { + it("should return escrow for authorized user", async () => { + vi.mocked(prisma.escrow.findUnique).mockResolvedValue(mockEscrow as never); + + const req = mockReq({}, { id: "clx_esc_001" }); + withUser(req, "clx1234567891", "CLIENT"); + const res = mockRes(); + + await escrowController.getEscrow(req, res); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ escrow: expect.any(Object) }), + }) + ); + }); + + it("should deny access to non-participant", async () => { + vi.mocked(prisma.escrow.findUnique).mockResolvedValue(mockEscrow as never); + + const req = mockReq({}, { id: "clx_esc_001" }); + withUser(req, "clx_unauthorized", "FREELANCER"); + const res = mockRes(); + + await expect(escrowController.getEscrow(req, res)).rejects.toThrow("You don't have access to this escrow"); + }); + }); +}); diff --git a/src/__tests__/integration/invoice.test.ts b/src/__tests__/integration/invoice.test.ts new file mode 100644 index 0000000..82c57b7 --- /dev/null +++ b/src/__tests__/integration/invoice.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Request, Response } from "express"; +import { prisma } from "../../config/prisma.js"; +import * as invoiceController from "../../controllers/invoice.controller.js"; +import { mockUser, mockClient, mockInvoice } from "../fixtures/index.js"; + +function mockReq(body: Record = {}, params: Record = {}, query: Record = {}): Request { + return { + body, + params, + query, + headers: {}, + ip: "127.0.0.1", + socket: { remoteAddress: "127.0.0.1" }, + } as unknown as Request; +} + +function mockRes(): Response { + return { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } as unknown as Response; +} + +function withUser(req: Request, userId: string, role: string): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (req as any).user = { userId, role }; +} + +describe("Invoice Controller", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("createInvoice", () => { + it("should create an invoice as freelancer", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(mockClient as never); + vi.mocked(prisma.invoice.create).mockResolvedValue(mockInvoice as never); + + const req = mockReq({ + recipientId: "clx1234567891", + title: "Web Development", + description: "Frontend work", + amount: 1500, + }); + withUser(req, "clx1234567890", "FREELANCER"); + const res = mockRes(); + + await invoiceController.createInvoice(req, res); + + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ invoice: expect.any(Object) }), + }) + ); + }); + + it("should reject invoice creation by client", async () => { + const req = mockReq({ + recipientId: "clx1234567890", + title: "Web Development", + description: "Frontend work", + amount: 1500, + }); + withUser(req, "clx1234567891", "CLIENT"); + const res = mockRes(); + + await expect(invoiceController.createInvoice(req, res)).rejects.toThrow("Only freelancers can create invoices"); + }); + + it("should reject self-invoice", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as never); + + const req = mockReq({ + recipientId: "clx1234567890", + title: "Self Invoice", + description: "Test", + amount: 100, + }); + withUser(req, "clx1234567890", "FREELANCER"); + const res = mockRes(); + + await expect(invoiceController.createInvoice(req, res)).rejects.toThrow("Cannot create invoice for yourself"); + }); + }); + + describe("getInvoices", () => { + it("should return paginated invoices for freelancer", async () => { + vi.mocked(prisma.invoice.findMany).mockResolvedValue([mockInvoice] as never); + vi.mocked(prisma.invoice.count).mockResolvedValue(1); + + const req = mockReq({}, {}, { page: "1", limit: "20" }); + withUser(req, "clx1234567890", "FREELANCER"); + const res = mockRes(); + + await invoiceController.getInvoices(req, res); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + invoices: expect.any(Array), + pagination: expect.objectContaining({ + page: 1, + limit: 20, + total: 1, + totalPages: 1, + }), + }), + }) + ); + }); + }); + + describe("getInvoice", () => { + it("should return invoice by ID for creator", async () => { + vi.mocked(prisma.invoice.findUnique).mockResolvedValue(mockInvoice as never); + + const req = mockReq({}, { id: "clx_inv_001" }); + withUser(req, "clx1234567890", "FREELANCER"); + const res = mockRes(); + + await invoiceController.getInvoice(req, res); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ invoice: expect.any(Object) }), + }) + ); + }); + + it("should deny access to non-participant", async () => { + vi.mocked(prisma.invoice.findUnique).mockResolvedValue(mockInvoice as never); + + const req = mockReq({}, { id: "clx_inv_001" }); + withUser(req, "clx_unauthorized", "FREELANCER"); + const res = mockRes(); + + await expect(invoiceController.getInvoice(req, res)).rejects.toThrow("You don't have access to this invoice"); + }); + }); + + describe("updateInvoice", () => { + it("should update invoice title", async () => { + vi.mocked(prisma.invoice.findUnique).mockResolvedValue(mockInvoice as never); + vi.mocked(prisma.invoice.update).mockResolvedValue({ + ...mockInvoice, + title: "Updated Title", + } as never); + + const req = mockReq({ title: "Updated Title" }, { id: "clx_inv_001" }); + withUser(req, "clx1234567890", "FREELANCER"); + const res = mockRes(); + + await invoiceController.updateInvoice(req, res); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + invoice: expect.objectContaining({ title: "Updated Title" }), + }), + }) + ); + }); + + it("should reject update by non-creator", async () => { + vi.mocked(prisma.invoice.findUnique).mockResolvedValue(mockInvoice as never); + + const req = mockReq({ title: "Hacked" }, { id: "clx_inv_001" }); + withUser(req, "clx1234567891", "CLIENT"); + const res = mockRes(); + + await expect(invoiceController.updateInvoice(req, res)).rejects.toThrow("Only the invoice creator can update it"); + }); + }); + + describe("deleteInvoice", () => { + it("should delete draft invoice", async () => { + vi.mocked(prisma.invoice.findUnique).mockResolvedValue(mockInvoice as never); + vi.mocked(prisma.invoice.delete).mockResolvedValue(mockInvoice as never); + + const req = mockReq({}, { id: "clx_inv_001" }); + withUser(req, "clx1234567890", "FREELANCER"); + const res = mockRes(); + + await invoiceController.deleteInvoice(req, res); + + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: { message: "Invoice deleted successfully" }, + }); + }); + + it("should reject deleting non-draft invoice", async () => { + vi.mocked(prisma.invoice.findUnique).mockResolvedValue({ + ...mockInvoice, + status: "PENDING", + } as never); + + const req = mockReq({}, { id: "clx_inv_001" }); + withUser(req, "clx1234567890", "FREELANCER"); + const res = mockRes(); + + await expect(invoiceController.deleteInvoice(req, res)).rejects.toThrow("Only draft invoices can be deleted"); + }); + }); +}); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts new file mode 100644 index 0000000..aa72c73 --- /dev/null +++ b/src/__tests__/setup.ts @@ -0,0 +1,58 @@ +import { vi } from "vitest"; + +vi.mock("../config/prisma.js", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + count: vi.fn(), + }, + invoice: { + findUnique: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + count: vi.fn(), + }, + escrow: { + findUnique: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + count: vi.fn(), + }, + payment: { + create: vi.fn(), + findMany: vi.fn(), + }, + notification: { + create: vi.fn(), + findMany: vi.fn(), + }, + }, +})); + +vi.mock("../services/stellar.service.js", () => ({ + createEscrowContract: vi.fn().mockResolvedValue({ + success: true, + txHash: "tx_mock_123", + contractId: "contract_mock_123", + }), + fundEscrow: vi.fn().mockResolvedValue({ + success: true, + txHash: "tx_mock_fund", + }), + releaseEscrowFunds: vi.fn().mockResolvedValue({ + success: true, + txHash: "tx_mock_release", + }), + refundEscrowFunds: vi.fn().mockResolvedValue({ + success: true, + txHash: "tx_mock_refund", + }), +})); diff --git a/src/__tests__/unit/config.test.ts b/src/__tests__/unit/config.test.ts new file mode 100644 index 0000000..1fdeb6d --- /dev/null +++ b/src/__tests__/unit/config.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { config } from "../../config/index.js"; + +describe("Config", () => { + it("should export config object with required fields", () => { + expect(config).toBeDefined(); + expect(config.port).toBeDefined(); + expect(typeof config.port).toBe("number"); + }); + + it("should have correct default port", () => { + expect(config.port).toBe(3001); + }); + + it("should have jwt config", () => { + expect(config.jwt).toBeDefined(); + expect(typeof config.jwt.secret).toBe("string"); + expect(typeof config.jwt.expiresIn).toBe("number"); + expect(typeof config.jwt.refreshExpiresIn).toBe("number"); + }); + + it("should have stellar config", () => { + expect(config.stellar).toBeDefined(); + expect(config.stellar.network).toBe("testnet"); + expect(config.stellar.horizonUrl).toContain("stellar.org"); + }); + + it("should have cors config", () => { + expect(config.cors).toBeDefined(); + expect(config.cors.origin).toBeDefined(); + }); + + it("should have nodeEnv default to development", () => { + expect(config.nodeEnv).toBeDefined(); + }); +}); diff --git a/src/__tests__/unit/errors.test.ts b/src/__tests__/unit/errors.test.ts new file mode 100644 index 0000000..39a02fc --- /dev/null +++ b/src/__tests__/unit/errors.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import { + AppError, + NotFoundError, + UnauthorizedError, + ForbiddenError, + BadRequestError, + ConflictError, +} from "../../utils/errors.js"; + +describe("Error Classes", () => { + describe("AppError", () => { + it("should create error with message and status code", () => { + const error = new AppError("Test error", 500); + expect(error.message).toBe("Test error"); + expect(error.statusCode).toBe(500); + expect(error.isOperational).toBe(true); + expect(error).toBeInstanceOf(Error); + }); + + it("should set isOperational to false when specified", () => { + const error = new AppError("System error", 500, false); + expect(error.isOperational).toBe(false); + }); + }); + + describe("NotFoundError", () => { + it("should have status 404 and default message", () => { + const error = new NotFoundError(); + expect(error.statusCode).toBe(404); + expect(error.message).toBe("Resource not found"); + }); + + it("should accept custom message", () => { + const error = new NotFoundError("User not found"); + expect(error.message).toBe("User not found"); + }); + }); + + describe("UnauthorizedError", () => { + it("should have status 401 and default message", () => { + const error = new UnauthorizedError(); + expect(error.statusCode).toBe(401); + expect(error.message).toBe("Unauthorized"); + }); + + it("should accept custom message", () => { + const error = new UnauthorizedError("Invalid token"); + expect(error.message).toBe("Invalid token"); + }); + }); + + describe("ForbiddenError", () => { + it("should have status 403 and default message", () => { + const error = new ForbiddenError(); + expect(error.statusCode).toBe(403); + expect(error.message).toBe("Forbidden"); + }); + + it("should accept custom message", () => { + const error = new ForbiddenError("Insufficient permissions"); + expect(error.message).toBe("Insufficient permissions"); + }); + }); + + describe("BadRequestError", () => { + it("should have status 400 and default message", () => { + const error = new BadRequestError(); + expect(error.statusCode).toBe(400); + expect(error.message).toBe("Bad request"); + }); + + it("should accept custom message", () => { + const error = new BadRequestError("Invalid input"); + expect(error.message).toBe("Invalid input"); + }); + }); + + describe("ConflictError", () => { + it("should have status 409 and default message", () => { + const error = new ConflictError(); + expect(error.statusCode).toBe(409); + expect(error.message).toBe("Resource already exists"); + }); + + it("should accept custom message", () => { + const error = new ConflictError("Email already registered"); + expect(error.message).toBe("Email already registered"); + }); + }); +}); diff --git a/src/__tests__/unit/pagination.test.ts b/src/__tests__/unit/pagination.test.ts new file mode 100644 index 0000000..0345e58 --- /dev/null +++ b/src/__tests__/unit/pagination.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { getPaginationParams, getPaginationMeta, getSkipTake } from "../../utils/pagination.js"; + +describe("Pagination Utils", () => { + describe("getPaginationParams", () => { + it("should return defaults for empty query", () => { + const result = getPaginationParams({}); + expect(result).toEqual({ page: 1, limit: 20 }); + }); + + it("should parse page and limit from query", () => { + const result = getPaginationParams({ page: "3", limit: "50" }); + expect(result).toEqual({ page: 3, limit: 50 }); + }); + + it("should enforce minimum page of 1", () => { + const result = getPaginationParams({ page: "0" }); + expect(result.page).toBe(1); + }); + + it("should enforce negative page to 1", () => { + const result = getPaginationParams({ page: "-5" }); + expect(result.page).toBe(1); + }); + + it("should enforce maximum limit of 100", () => { + const result = getPaginationParams({ limit: "200" }); + expect(result.limit).toBe(100); + }); + + it("should default limit to 20 for zero value", () => { + const result = getPaginationParams({ limit: "0" }); + expect(result.limit).toBe(20); + }); + + it("should handle non-numeric strings", () => { + const result = getPaginationParams({ page: "abc", limit: "xyz" }); + expect(result).toEqual({ page: 1, limit: 20 }); + }); + }); + + describe("getPaginationMeta", () => { + it("should calculate correct total pages", () => { + const result = getPaginationMeta(1, 10, 55); + expect(result).toEqual({ + page: 1, + limit: 10, + total: 55, + totalPages: 6, + }); + }); + + it("should handle exact division", () => { + const result = getPaginationMeta(2, 10, 20); + expect(result.totalPages).toBe(2); + }); + + it("should handle zero total", () => { + const result = getPaginationMeta(1, 20, 0); + expect(result.totalPages).toBe(0); + }); + + it("should handle single item", () => { + const result = getPaginationMeta(1, 20, 1); + expect(result.totalPages).toBe(1); + }); + }); + + describe("getSkipTake", () => { + it("should calculate skip for first page", () => { + const result = getSkipTake(1, 20); + expect(result).toEqual({ skip: 0, take: 20 }); + }); + + it("should calculate skip for second page", () => { + const result = getSkipTake(2, 20); + expect(result).toEqual({ skip: 20, take: 20 }); + }); + + it("should calculate skip for third page with custom limit", () => { + const result = getSkipTake(3, 10); + expect(result).toEqual({ skip: 20, take: 10 }); + }); + }); +}); diff --git a/src/__tests__/unit/sanitize.test.ts b/src/__tests__/unit/sanitize.test.ts new file mode 100644 index 0000000..cb53cc8 --- /dev/null +++ b/src/__tests__/unit/sanitize.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { sanitizeString, sanitizeUrl } from "../../middleware/sanitize.js"; + +describe("Sanitize Middleware", () => { + describe("sanitizeString", () => { + it("should remove HTML tags", () => { + const result = sanitizeString("Hello"); + expect(result).toBe("Hello"); + expect(result).not.toContain(""); + expect(result).toBe(""); + }); + + it("should block vbscript: URLs", () => { + const result = sanitizeUrl("vbscript:MsgBox('xss')"); + expect(result).toBe(""); + }); + + it("should block file: URLs", () => { + const result = sanitizeUrl("file:///etc/passwd"); + expect(result).toBe(""); + }); + + it("should trim whitespace from URLs", () => { + const result = sanitizeUrl(" https://example.com "); + expect(result).toBe("https://example.com"); + }); + + it("should handle http URLs", () => { + const result = sanitizeUrl("http://example.com"); + expect(result).toBe("http://example.com"); + }); + }); +}); diff --git a/src/__tests__/unit/validation.test.ts b/src/__tests__/unit/validation.test.ts new file mode 100644 index 0000000..cda7ba5 --- /dev/null +++ b/src/__tests__/unit/validation.test.ts @@ -0,0 +1,258 @@ +import { describe, it, expect } from "vitest"; +import { registerSchema, loginSchema, refreshTokenSchema } from "../../schemas/auth.schema.js"; +import { updateProfileSchema, avatarUploadSchema } from "../../schemas/user.schema.js"; +import { createInvoiceSchema, updateInvoiceSchema, listInvoicesSchema } from "../../schemas/invoice.schema.js"; +import { createEscrowSchema, fundEscrowSchema, releaseEscrowSchema, refundEscrowSchema } from "../../schemas/escrow.schema.js"; + +describe("Auth Schemas", () => { + describe("registerSchema", () => { + it("should accept valid registration data", () => { + const data = { + fullname: "John Doe", + email: "john@example.com", + password: "password123", + role: "FREELANCER" as const, + }; + const result = registerSchema.safeParse(data); + expect(result.success).toBe(true); + }); + + it("should accept registration with optional fields", () => { + const data = { + fullname: "John Doe", + email: "john@example.com", + password: "password123", + role: "CLIENT" as const, + walletAddress: "GCKFBEIYVYQV6FYNUWZV6MO7VVI7RMUO6ESLO45S73JQ27LHJKR2", + country: "US", + }; + const result = registerSchema.safeParse(data); + expect(result.success).toBe(true); + }); + + it("should reject short name", () => { + const data = { + fullname: "J", + email: "john@example.com", + password: "password123", + role: "FREELANCER" as const, + }; + const result = registerSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should reject invalid email", () => { + const data = { + fullname: "John Doe", + email: "not-an-email", + password: "password123", + role: "FREELANCER" as const, + }; + const result = registerSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should reject short password", () => { + const data = { + fullname: "John Doe", + email: "john@example.com", + password: "1234567", + role: "FREELANCER" as const, + }; + const result = registerSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should reject invalid role", () => { + const data = { + fullname: "John Doe", + email: "john@example.com", + password: "password123", + role: "ADMIN", + }; + const result = registerSchema.safeParse(data); + expect(result.success).toBe(false); + }); + }); + + describe("loginSchema", () => { + it("should accept valid login data", () => { + const data = { email: "john@example.com", password: "password123" }; + const result = loginSchema.safeParse(data); + expect(result.success).toBe(true); + }); + + it("should reject empty password", () => { + const data = { email: "john@example.com", password: "" }; + const result = loginSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should reject invalid email", () => { + const data = { email: "invalid", password: "password123" }; + const result = loginSchema.safeParse(data); + expect(result.success).toBe(false); + }); + }); + + describe("refreshTokenSchema", () => { + it("should accept valid refresh token", () => { + const result = refreshTokenSchema.safeParse({ refreshToken: "valid-token" }); + expect(result.success).toBe(true); + }); + + it("should reject empty refresh token", () => { + const result = refreshTokenSchema.safeParse({ refreshToken: "" }); + expect(result.success).toBe(false); + }); + }); +}); + +describe("User Schemas", () => { + describe("updateProfileSchema", () => { + it("should accept valid update data", () => { + const data = { fullname: "Jane Doe", country: "UK" }; + const result = updateProfileSchema.safeParse(data); + expect(result.success).toBe(true); + }); + + it("should accept empty update", () => { + const result = updateProfileSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("should reject invalid email format", () => { + const result = updateProfileSchema.safeParse({ email: "not-email" }); + expect(result.success).toBe(false); + }); + + it("should reject invalid URL for profileImage", () => { + const result = updateProfileSchema.safeParse({ profileImage: "not-a-url" }); + expect(result.success).toBe(false); + }); + }); + + describe("avatarUploadSchema", () => { + it("should accept valid URL", () => { + const result = avatarUploadSchema.safeParse({ profileImage: "https://example.com/avatar.png" }); + expect(result.success).toBe(true); + }); + + it("should reject invalid URL", () => { + const result = avatarUploadSchema.safeParse({ profileImage: "not-a-url" }); + expect(result.success).toBe(false); + }); + }); +}); + +describe("Invoice Schemas", () => { + describe("createInvoiceSchema", () => { + it("should accept valid invoice data", () => { + const data = { + recipientId: "clx123", + title: "Test Invoice", + description: "Test description", + amount: 100, + }; + const result = createInvoiceSchema.safeParse(data); + expect(result.success).toBe(true); + }); + + it("should reject negative amount", () => { + const data = { + recipientId: "clx123", + title: "Test Invoice", + description: "Test description", + amount: -100, + }; + const result = createInvoiceSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should reject zero amount", () => { + const data = { + recipientId: "clx123", + title: "Test Invoice", + description: "Test description", + amount: 0, + }; + const result = createInvoiceSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should reject missing required fields", () => { + const result = createInvoiceSchema.safeParse({ title: "Test" }); + expect(result.success).toBe(false); + }); + }); + + describe("updateInvoiceSchema", () => { + it("should accept partial update", () => { + const result = updateInvoiceSchema.safeParse({ title: "Updated Title" }); + expect(result.success).toBe(true); + }); + + it("should accept status update", () => { + const result = updateInvoiceSchema.safeParse({ status: "PENDING" }); + expect(result.success).toBe(true); + }); + + it("should reject invalid status", () => { + const result = updateInvoiceSchema.safeParse({ status: "INVALID_STATUS" }); + expect(result.success).toBe(false); + }); + }); + + describe("listInvoicesSchema", () => { + it("should accept valid query params", () => { + const result = listInvoicesSchema.safeParse({ page: "1", limit: "20" }); + expect(result.success).toBe(true); + }); + + it("should accept empty query", () => { + const result = listInvoicesSchema.safeParse({}); + expect(result.success).toBe(true); + }); + }); +}); + +describe("Escrow Schemas", () => { + describe("createEscrowSchema", () => { + it("should accept valid escrow data", () => { + const data = { invoiceId: "clx123", contractId: "contract_123" }; + const result = createEscrowSchema.safeParse(data); + expect(result.success).toBe(true); + }); + + it("should reject missing invoiceId", () => { + const result = createEscrowSchema.safeParse({ contractId: "contract_123" }); + expect(result.success).toBe(false); + }); + }); + + describe("fundEscrowSchema", () => { + it("should accept valid txHash", () => { + const result = fundEscrowSchema.safeParse({ txHash: "tx_abc123" }); + expect(result.success).toBe(true); + }); + + it("should reject empty txHash", () => { + const result = fundEscrowSchema.safeParse({ txHash: "" }); + expect(result.success).toBe(false); + }); + }); + + describe("releaseEscrowSchema", () => { + it("should accept valid txHash", () => { + const result = releaseEscrowSchema.safeParse({ txHash: "tx_abc123" }); + expect(result.success).toBe(true); + }); + }); + + describe("refundEscrowSchema", () => { + it("should accept valid txHash", () => { + const result = refundEscrowSchema.safeParse({ txHash: "tx_abc123" }); + expect(result.success).toBe(true); + }); + }); +}); diff --git a/src/config/swagger.ts b/src/config/swagger.ts new file mode 100644 index 0000000..10eb987 --- /dev/null +++ b/src/config/swagger.ts @@ -0,0 +1,184 @@ +import swaggerJsdoc from "swagger-jsdoc"; +import type { Options } from "swagger-jsdoc"; + +const options: Options = { + definition: { + openapi: "3.0.0", + info: { + title: "StellFlow Backend API", + version: "1.0.0", + description: "StellFlow Backend API - Express 5 + Prisma + PostgreSQL with Stellar blockchain integration", + contact: { + name: "StellFlow Team", + }, + }, + servers: [ + { + url: "http://localhost:3001", + description: "Development server", + }, + { + url: "https://api.stellflow.io", + description: "Production server", + }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + description: "Enter your JWT access token", + }, + cookieAuth: { + type: "apiKey", + in: "cookie", + name: "refreshToken", + description: "Refresh token stored in HTTP-only cookie", + }, + }, + schemas: { + User: { + type: "object", + properties: { + id: { type: "string", description: "User ID (cuid)" }, + fullname: { type: "string", description: "Full name" }, + email: { type: "string", format: "email", description: "Email address" }, + role: { type: "string", enum: ["FREELANCER", "CLIENT", "ADMIN"], description: "User role" }, + walletAddress: { type: "string", nullable: true, description: "Stellar wallet address" }, + profileImage: { type: "string", nullable: true, description: "Profile image URL" }, + country: { type: "string", nullable: true, description: "Country" }, + isVerified: { type: "boolean", description: "Email verification status" }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + Invoice: { + type: "object", + properties: { + id: { type: "string" }, + creatorId: { type: "string" }, + recipientId: { type: "string" }, + title: { type: "string" }, + description: { type: "string" }, + amount: { type: "number" }, + currency: { type: "string", default: "USDC" }, + status: { type: "string", enum: ["DRAFT", "PENDING", "FUNDED", "IN_ESCROW", "COMPLETED", "CANCELLED", "DISPUTED"] }, + dueDate: { type: "string", format: "date-time", nullable: true }, + paidAt: { type: "string", format: "date-time", nullable: true }, + txHash: { type: "string", nullable: true }, + contractId: { type: "string", nullable: true }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + Escrow: { + type: "object", + properties: { + id: { type: "string" }, + invoiceId: { type: "string" }, + clientId: { type: "string" }, + freelancerId: { type: "string" }, + contractId: { type: "string" }, + txHash: { type: "string", nullable: true }, + amount: { type: "number" }, + currency: { type: "string", default: "USDC" }, + status: { type: "string", enum: ["PENDING", "FUNDED", "RELEASED", "REFUNDED", "DISPUTED"] }, + fundedAt: { type: "string", format: "date-time", nullable: true }, + releasedAt: { type: "string", format: "date-time", nullable: true }, + refundedAt: { type: "string", format: "date-time", nullable: true }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + ErrorResponse: { + type: "object", + properties: { + success: { type: "boolean", example: false }, + error: { type: "string" }, + }, + }, + SuccessResponse: { + type: "object", + properties: { + success: { type: "boolean", example: true }, + data: { type: "object" }, + }, + }, + PaginationMeta: { + type: "object", + properties: { + page: { type: "integer" }, + limit: { type: "integer" }, + total: { type: "integer" }, + totalPages: { type: "integer" }, + }, + }, + RegisterRequest: { + type: "object", + required: ["fullname", "email", "password", "role"], + properties: { + fullname: { type: "string", minLength: 2, description: "Full name" }, + email: { type: "string", format: "email", description: "Email address" }, + password: { type: "string", minLength: 8, description: "Password (min 8 characters)" }, + role: { type: "string", enum: ["FREELANCER", "CLIENT"], description: "User role" }, + walletAddress: { type: "string", description: "Stellar wallet address (optional)" }, + country: { type: "string", description: "Country (optional)" }, + }, + }, + LoginRequest: { + type: "object", + required: ["email", "password"], + properties: { + email: { type: "string", format: "email" }, + password: { type: "string", minLength: 1 }, + }, + }, + AuthResponse: { + type: "object", + properties: { + success: { type: "boolean", example: true }, + data: { + type: "object", + properties: { + user: { $ref: "#/components/schemas/User" }, + accessToken: { type: "string", description: "JWT access token" }, + refreshToken: { type: "string", description: "JWT refresh token" }, + }, + }, + }, + }, + CreateInvoiceRequest: { + type: "object", + required: ["recipientId", "title", "description", "amount"], + properties: { + recipientId: { type: "string", description: "Recipient user ID" }, + title: { type: "string", description: "Invoice title" }, + description: { type: "string", description: "Invoice description" }, + amount: { type: "number", exclusiveMinimum: 0, description: "Amount" }, + currency: { type: "string", default: "USDC", description: "Currency code" }, + dueDate: { type: "string", format: "date-time", description: "Due date (ISO 8601)" }, + }, + }, + CreateEscrowRequest: { + type: "object", + required: ["invoiceId", "contractId"], + properties: { + invoiceId: { type: "string", description: "Invoice ID" }, + contractId: { type: "string", description: "Stellar contract ID" }, + }, + }, + FundEscrowRequest: { + type: "object", + required: ["txHash"], + properties: { + txHash: { type: "string", description: "Stellar transaction hash" }, + }, + }, + }, + }, + }, + apis: ["./src/routes/*.ts"], +}; + +export const swaggerSpec = swaggerJsdoc(options); diff --git a/src/index.ts b/src/index.ts index d1fb554..508ea82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,13 +3,16 @@ import express from "express"; import helmet from "helmet"; import cors from "cors"; import morgan from "morgan"; -import rateLimit from "express-rate-limit"; import cookieParser from "cookie-parser"; +import swaggerUi from "swagger-ui-express"; +import { swaggerSpec } from "./config/swagger.js"; import authRoutes from "./routes/auth.routes.js"; import userRoutes from "./routes/user.routes.js"; import invoiceRoutes from "./routes/invoice.routes.js"; import escrowRoutes from "./routes/escrow.routes.js"; import { errorHandler } from "./middleware/errorHandler.js"; +import { globalLimiter, apiLimiter } from "./middleware/rateLimiter.js"; +import { sanitizeBody, sanitizeQuery, sanitizeParams, validateContentType } from "./middleware/sanitize.js"; const app = express(); const PORT = process.env["PORT"] ?? 3001; @@ -20,27 +23,35 @@ app.use(morgan("dev")); app.use(express.json({ limit: "10mb" })); app.use(cookieParser()); -const limiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 100, - standardHeaders: true, - legacyHeaders: false, +app.use(globalLimiter); +app.use(sanitizeBody); +app.use(sanitizeQuery); +app.use(sanitizeParams); +app.use(validateContentType()); + +app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec, { + customCss: ".swagger-ui .topbar { display: none }", + customSiteTitle: "StellFlow API Documentation", +})); + +app.get("/api-docs.json", (_req, res) => { + res.json(swaggerSpec); }); -app.use("/api/", limiter); app.get("/health", (_req, res) => { res.json({ status: "ok", timestamp: new Date().toISOString() }); }); app.use("/api/auth", authRoutes); -app.use("/api/users", userRoutes); -app.use("/api/invoices", invoiceRoutes); -app.use("/api/escrows", escrowRoutes); +app.use("/api/users", apiLimiter, userRoutes); +app.use("/api/invoices", apiLimiter, invoiceRoutes); +app.use("/api/escrows", apiLimiter, escrowRoutes); app.use(errorHandler); app.listen(PORT, () => { console.log(`StellFlow API running on port ${PORT}`); + console.log(`API docs available at http://localhost:${PORT}/api-docs`); }); export default app; diff --git a/src/middleware/rateLimiter.ts b/src/middleware/rateLimiter.ts new file mode 100644 index 0000000..e1ef373 --- /dev/null +++ b/src/middleware/rateLimiter.ts @@ -0,0 +1,117 @@ +import type { Request, Response, NextFunction } from "express"; +import rateLimit from "express-rate-limit"; + +interface RateLimitStore { + hits: Map; + failures: Map; +} + +const store: RateLimitStore = { + hits: new Map(), + failures: new Map(), +}; + +const LOCKOUT_THRESHOLD = 5; +const LOCKOUT_DURATION = 15 * 60 * 1000; + +function getOrCreateFailure(key: string) { + let failure = store.failures.get(key); + if (!failure || Date.now() > failure.lockedUntil) { + failure = { count: 0, lockedUntil: 0 }; + store.failures.set(key, failure); + } + return failure; +} + +export function recordFailedAttempt(key: string): { locked: boolean; attemptsRemaining: number } { + const failure = getOrCreateFailure(key); + failure.count += 1; + + if (failure.count >= LOCKOUT_THRESHOLD) { + failure.lockedUntil = Date.now() + LOCKOUT_DURATION; + return { locked: true, attemptsRemaining: 0 }; + } + + return { locked: false, attemptsRemaining: LOCKOUT_THRESHOLD - failure.count }; +} + +export function resetFailedAttempts(key: string): void { + store.failures.delete(key); +} + +export function isLockedOut(key: string): boolean { + const failure = store.failures.get(key); + if (!failure) return false; + if (Date.now() > failure.lockedUntil) { + store.failures.delete(key); + return false; + } + return true; +} + +export function getRemainingLockoutTime(key: string): number { + const failure = store.failures.get(key); + if (!failure) return 0; + return Math.max(0, failure.lockedUntil - Date.now()); +} + +export const globalLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 200, + standardHeaders: true, + legacyHeaders: false, + message: { success: false, error: "Too many requests, please try again later" }, + keyGenerator: (req) => req.ip ?? req.socket.remoteAddress ?? "unknown", +}); + +export const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 20, + standardHeaders: true, + legacyHeaders: false, + message: { success: false, error: "Too many authentication attempts, please try again later" }, + keyGenerator: (req) => req.ip ?? req.socket.remoteAddress ?? "unknown", +}); + +export const strictAuthLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 5, + standardHeaders: true, + legacyHeaders: false, + message: { success: false, error: "Too many login attempts, account temporarily locked" }, + keyGenerator: (req) => { + const ip = req.ip ?? req.socket.remoteAddress ?? "unknown"; + const email = req.body?.email as string | undefined; + return email ? `${ip}:${email}` : ip; + }, +}); + +export const apiLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 100, + standardHeaders: true, + legacyHeaders: false, + message: { success: false, error: "Too many API requests, please try again later" }, + keyGenerator: (req) => { + const userId = (req as { user?: { userId?: string } }).user?.userId; + return userId ?? (req.ip ?? req.socket.remoteAddress ?? "unknown"); + }, +}); + +export function lockoutMiddleware(req: Request, res: Response, next: NextFunction): void { + const ip = req.ip ?? req.socket?.remoteAddress ?? "unknown"; + const email = req.body?.email as string | undefined; + const key = email ? `${ip}:${email}` : ip; + + if (isLockedOut(key)) { + const remainingMs = getRemainingLockoutTime(key); + const remainingMinutes = Math.ceil(remainingMs / 60000); + res.status(429).json({ + success: false, + error: `Account temporarily locked. Try again in ${remainingMinutes} minute(s)`, + }); + return; + } + + next(); +} diff --git a/src/middleware/sanitize.ts b/src/middleware/sanitize.ts new file mode 100644 index 0000000..e627791 --- /dev/null +++ b/src/middleware/sanitize.ts @@ -0,0 +1,135 @@ +import type { Request, Response, NextFunction } from "express"; +import sanitizeHtml from "sanitize-html"; + +const sanitizeOptions: sanitizeHtml.IOptions = { + allowedTags: [], + allowedAttributes: {}, + disallowedTagsMode: "discard", +}; + +function sanitizeString(value: string): string { + return sanitizeHtml(value, sanitizeOptions).trim(); +} + +function sanitizeValue(value: unknown): unknown { + if (typeof value === "string") { + return sanitizeString(value); + } + if (Array.isArray(value)) { + return value.map(sanitizeValue); + } + if (value !== null && typeof value === "object") { + return sanitizeObject(value as Record); + } + return value; +} + +function sanitizeObject(obj: Record): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(obj)) { + sanitized[key] = sanitizeValue(value); + } + return sanitized; +} + +const DANGEROUS_URL_PATTERNS = [ + /^javascript:/i, + /^data:/i, + /^vbscript:/i, + /^file:/i, +]; + +function sanitizeUrl(url: string): string { + const trimmed = url.trim(); + for (const pattern of DANGEROUS_URL_PATTERNS) { + if (pattern.test(trimmed)) { + return ""; + } + } + return trimmed; +} + +export function sanitizeBody(req: Request, _res: Response, next: NextFunction): void { + if (req.body && typeof req.body === "object") { + req.body = sanitizeObject(req.body as Record); + } + next(); +} + +export function sanitizeQuery(req: Request, _res: Response, next: NextFunction): void { + if (req.query && typeof req.query === "object") { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(req.query)) { + const sanitizedValue = sanitizeValue(value); + sanitized[key] = typeof sanitizedValue === "string" ? sanitizedValue : String(sanitizedValue ?? ""); + } + req.query = sanitized; + } + next(); +} + +export function sanitizeParams(req: Request, _res: Response, next: NextFunction): void { + if (req.params && typeof req.params === "object") { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(req.params)) { + sanitized[key] = sanitizeValue(value); + } + req.params = sanitized as Record; + } + next(); +} + +export function validateUrlField(fieldName: string) { + return (req: Request, res: Response, next: NextFunction): void => { + const value = req.body?.[fieldName] as string | undefined; + if (value) { + const sanitized = sanitizeUrl(value); + if (!sanitized) { + res.status(400).json({ + success: false, + error: `Invalid URL provided for ${fieldName}`, + }); + return; + } + req.body[fieldName] = sanitized; + } + next(); + }; +} + +export const MAX_UPLOAD_SIZE = 5 * 1024 * 1024; + +export function validateFileSize(maxSize: number = MAX_UPLOAD_SIZE) { + return (req: Request, res: Response, next: NextFunction): void => { + const contentLength = req.headers["content-length"]; + if (contentLength && parseInt(contentLength, 10) > maxSize) { + res.status(413).json({ + success: false, + error: `File too large. Maximum size is ${Math.round(maxSize / 1024 / 1024)}MB`, + }); + return; + } + next(); + }; +} + +export const ALLOWED_CONTENT_TYPES = [ + "application/json", + "application/x-www-form-urlencoded", +]; + +export function validateContentType(allowedTypes: string[] = ALLOWED_CONTENT_TYPES) { + return (req: Request, res: Response, next: NextFunction): void => { + const contentType = req.headers["content-type"]; + if (contentType && !allowedTypes.some((type) => contentType.includes(type))) { + res.status(415).json({ + success: false, + error: `Unsupported content type: ${contentType}`, + }); + return; + } + next(); + }; +} + +export { sanitizeString, sanitizeUrl }; diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index bdff257..0f0e961 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -3,13 +3,121 @@ import { register, login, logout, refreshToken, getMe } from "../controllers/aut import { validate } from "../middleware/validate.js"; import { authenticate } from "../middleware/auth.js"; import { registerSchema, loginSchema } from "../schemas/auth.schema.js"; +import { authLimiter, strictAuthLimiter, lockoutMiddleware } from "../middleware/rateLimiter.js"; const router = Router(); -router.post("/register", validate(registerSchema), register); -router.post("/login", validate(loginSchema), login); +/** + * @openapi + * /api/auth/register: + * post: + * tags: [Auth] + * summary: Register a new user + * description: Create a new user account with email and password + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RegisterRequest' + * responses: + * 201: + * description: User registered successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AuthResponse' + * 400: + * description: Validation error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 409: + * description: Email already registered + * 429: + * description: Too many requests + */ +router.post("/register", authLimiter, validate(registerSchema), register); + +/** + * @openapi + * /api/auth/login: + * post: + * tags: [Auth] + * summary: Login to an account + * description: Authenticate with email and password. Account locks after 5 failed attempts. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LoginRequest' + * responses: + * 200: + * description: Login successful + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AuthResponse' + * 401: + * description: Invalid credentials + * 429: + * description: Account locked due to too many failed attempts + */ +router.post("/login", strictAuthLimiter, lockoutMiddleware, validate(loginSchema), login); + +/** + * @openapi + * /api/auth/logout: + * post: + * tags: [Auth] + * summary: Logout + * description: Clear the refresh token cookie + * responses: + * 200: + * description: Logged out successfully + */ router.post("/logout", logout); -router.post("/refresh-token", refreshToken); + +/** + * @openapi + * /api/auth/refresh-token: + * post: + * tags: [Auth] + * summary: Refresh access token + * description: Exchange refresh token for a new access token + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * refreshToken: + * type: string + * responses: + * 200: + * description: Token refreshed successfully + * 401: + * description: Invalid or expired refresh token + */ +router.post("/refresh-token", authLimiter, refreshToken); + +/** + * @openapi + * /api/auth/me: + * get: + * tags: [Auth] + * summary: Get current user + * description: Get the authenticated user's profile + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: User profile + * 401: + * description: Unauthorized + */ router.get("/me", authenticate, getMe); export default router; diff --git a/src/routes/escrow.routes.ts b/src/routes/escrow.routes.ts index af12595..243959c 100644 --- a/src/routes/escrow.routes.ts +++ b/src/routes/escrow.routes.ts @@ -19,10 +19,166 @@ const router = Router(); router.use(authenticate); +/** + * @openapi + * /api/escrows: + * post: + * tags: [Escrows] + * summary: Create an escrow + * description: Create a new escrow contract for an invoice (clients only) + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CreateEscrowRequest' + * responses: + * 201: + * description: Escrow created + * 400: + * description: Validation error or escrow already exists + * 401: + * description: Unauthorized + * 403: + * description: Only clients can create escrows + * 404: + * description: Invoice not found + */ router.post("/", validate(createEscrowSchema), createEscrow); + +/** + * @openapi + * /api/escrows/{id}: + * get: + * tags: [Escrows] + * summary: Get escrow by ID + * description: Get escrow details including payment history + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Escrow ID + * responses: + * 200: + * description: Escrow details + * 401: + * description: Unauthorized + * 403: + * description: No access to this escrow + * 404: + * description: Escrow not found + */ router.get("/:id", getEscrow); + +/** + * @openapi + * /api/escrows/{id}/fund: + * post: + * tags: [Escrows] + * summary: Fund an escrow + * description: Fund an escrow with a Stellar transaction (client only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/FundEscrowRequest' + * responses: + * 200: + * description: Escrow funded + * 400: + * description: Cannot fund in current status + * 401: + * description: Unauthorized + * 403: + * description: Only the client can fund + * 404: + * description: Escrow not found + */ router.post("/:id/fund", validate(fundEscrowSchema), fundEscrow); + +/** + * @openapi + * /api/escrows/{id}/release: + * post: + * tags: [Escrows] + * summary: Release escrow funds + * description: Release escrow funds to the freelancer (freelancer or admin only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/FundEscrowRequest' + * responses: + * 200: + * description: Escrow released + * 400: + * description: Cannot release in current status + * 401: + * description: Unauthorized + * 403: + * description: Only freelancer or admin can release + * 404: + * description: Escrow not found + */ router.post("/:id/release", validate(releaseEscrowSchema), releaseEscrow); + +/** + * @openapi + * /api/escrows/{id}/refund: + * post: + * tags: [Escrows] + * summary: Refund escrow + * description: Refund escrow funds to the client (admin only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/FundEscrowRequest' + * responses: + * 200: + * description: Escrow refunded + * 400: + * description: Cannot refund in current status + * 401: + * description: Unauthorized + * 403: + * description: Only admins can refund + * 404: + * description: Escrow not found + */ router.post("/:id/refund", validate(refundEscrowSchema), refundEscrow); export default router; diff --git a/src/routes/invoice.routes.ts b/src/routes/invoice.routes.ts index 9592e37..c69c680 100644 --- a/src/routes/invoice.routes.ts +++ b/src/routes/invoice.routes.ts @@ -14,10 +14,165 @@ const router = Router(); router.use(authenticate); -router.post("/", authorize("FREELANCER"), validate(createInvoiceSchema), createInvoice); -router.get("/", validate(invoiceQuerySchema, "query"), getInvoices); -router.get("/:id", getInvoiceById); -router.patch("/:id", authorize("FREELANCER"), validate(updateInvoiceSchema), updateInvoice); -router.delete("/:id", authorize("FREELANCER"), deleteInvoice); +/** + * @openapi + * /api/invoices: + * post: + * tags: [Invoices] + * summary: Create an invoice + * description: Create a new invoice (freelancers only) + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CreateInvoiceRequest' + * responses: + * 201: + * description: Invoice created + * 400: + * description: Validation error + * 401: + * description: Unauthorized + * 403: + * description: Only freelancers can create invoices + */ +router.post("/", validate(createInvoiceSchema), createInvoice); + +/** + * @openapi + * /api/invoices: + * get: + * tags: [Invoices] + * summary: List invoices + * description: Get paginated list of invoices for the authenticated user + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * description: Page number + * - in: query + * name: limit + * schema: + * type: integer + * default: 20 + * maximum: 100 + * description: Items per page + * - in: query + * name: status + * schema: + * type: string + * enum: [DRAFT, PENDING, FUNDED, IN_ESCROW, COMPLETED, CANCELLED, DISPUTED] + * description: Filter by status + * - in: query + * name: search + * schema: + * type: string + * description: Search in title and description + * responses: + * 200: + * description: Paginated invoice list + * 401: + * description: Unauthorized + */ +router.get("/", getInvoices); + +/** + * @openapi + * /api/invoices/{id}: + * get: + * tags: [Invoices] + * summary: Get invoice by ID + * description: Get a single invoice with details + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Invoice ID + * responses: + * 200: + * description: Invoice details + * 401: + * description: Unauthorized + * 403: + * description: No access to this invoice + * 404: + * description: Invoice not found + */ +router.get("/:id", getInvoice); + +/** + * @openapi + * /api/invoices/{id}: + * put: + * tags: [Invoices] + * summary: Update an invoice + * description: Update invoice fields (creator only). Status transitions are validated. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CreateInvoiceRequest' + * responses: + * 200: + * description: Invoice updated + * 400: + * description: Invalid status transition + * 401: + * description: Unauthorized + * 403: + * description: Only creator can update + * 404: + * description: Invoice not found + */ +router.put("/:id", validate(updateInvoiceSchema), updateInvoice); + +/** + * @openapi + * /api/invoices/{id}: + * delete: + * tags: [Invoices] + * summary: Delete an invoice + * description: Delete a draft invoice (creator only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Invoice deleted + * 400: + * description: Only draft invoices can be deleted + * 401: + * description: Unauthorized + * 403: + * description: Only creator can delete + * 404: + * description: Invoice not found + */ +router.delete("/:id", deleteInvoice); export default router; diff --git a/src/routes/user.routes.ts b/src/routes/user.routes.ts index 72820eb..8d76a7a 100644 --- a/src/routes/user.routes.ts +++ b/src/routes/user.routes.ts @@ -3,14 +3,120 @@ import { getProfile, updateProfile, deleteProfile, uploadAvatar } from "../contr import { validate } from "../middleware/validate.js"; import { authenticate } from "../middleware/auth.js"; import { updateProfileSchema, avatarUploadSchema } from "../schemas/user.schema.js"; +import { validateUrlField } from "../middleware/sanitize.js"; const router = Router(); router.use(authenticate); +/** + * @openapi + * /api/users/profile: + * get: + * tags: [Users] + * summary: Get user profile + * description: Get the authenticated user's full profile with completeness score + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: User profile with completeness + * 401: + * description: Unauthorized + * 404: + * description: User not found + */ router.get("/profile", getProfile); + +/** + * @openapi + * /api/users/profile: + * put: + * tags: [Users] + * summary: Update user profile + * description: Update the authenticated user's profile fields + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * fullname: + * type: string + * minLength: 2 + * email: + * type: string + * format: email + * country: + * type: string + * walletAddress: + * type: string + * profileImage: + * type: string + * format: uri + * responses: + * 200: + * description: Profile updated + * 400: + * description: Validation error + * 401: + * description: Unauthorized + * 403: + * description: Email or wallet already in use + */ router.put("/profile", validate(updateProfileSchema), updateProfile); + +/** + * @openapi + * /api/users/profile: + * delete: + * tags: [Users] + * summary: Delete user profile + * description: Permanently delete the authenticated user's account + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Profile deleted + * 401: + * description: Unauthorized + * 404: + * description: User not found + */ router.delete("/profile", deleteProfile); -router.post("/avatar", validate(avatarUploadSchema), uploadAvatar); + +/** + * @openapi + * /api/users/avatar: + * post: + * tags: [Users] + * summary: Upload avatar + * description: Set or update the user's profile image URL + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [profileImage] + * properties: + * profileImage: + * type: string + * format: uri + * description: URL of the profile image + * responses: + * 200: + * description: Avatar updated + * 400: + * description: Invalid image URL + * 401: + * description: Unauthorized + */ +router.post("/avatar", validate(avatarUploadSchema), validateUrlField("profileImage"), uploadAvatar); export default router; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..fba8461 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + setupFiles: ["./src/__tests__/setup.ts"], + include: ["src/__tests__/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + include: ["src/**/*.ts"], + exclude: ["src/__tests__/**", "src/types/**"], + }, + }, + resolve: { + alias: { + "@": "/src", + }, + }, +});