diff --git a/README.md b/README.md index 272da9cc..55d7833c 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,23 @@ curl -X POST http://localhost:2785/api/sessions/{sessionId}/webhooks \ --- +## 🤖 MCP (Model Context Protocol) + +OpenWA can expose its API to AI agents as **MCP tools**, additively and **off by default**. The existing REST API is unchanged — individual routes simply opt in, so every MCP tool maps 1:1 to an existing endpoint. + +Enable it by setting `MCP_ENABLED=true`. A Streamable-HTTP MCP endpoint then mounts at **`/mcp`**: + +```bash +MCP_ENABLED=true npm run start:dev +# MCP endpoint: http://localhost:2785/mcp +``` + +- **Auth is fully enforced.** Every tool call runs the same API-key guard as REST — pass your key via the `X-API-Key` header. Role requirements (`viewer`/`operator`/`admin`) and per-key session scoping apply exactly as they do over HTTP. (IP-allowlisted keys can't authorize MCP calls, since there's no client IP over MCP.) +- **What's exposed:** the JSON request/response operations across sessions, messages, groups, contacts, labels, channels, templates, webhooks, catalog, status, stats, plugins, infra and auth (API-key management). Streaming/binary and login-only endpoints are intentionally not exposed. +- **Disabled by default:** with `MCP_ENABLED` unset, the `/mcp` endpoint is not mounted and there is zero change to REST behavior. + +--- + ## 🛠 Tech Stack | Layer | Technology | diff --git a/eslint.config.mjs b/eslint.config.mjs index e97dd806..9cd49f57 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -29,6 +29,7 @@ export default tseslint.config( '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-floating-promises': 'warn', '@typescript-eslint/no-unsafe-argument': 'warn', + '@typescript-eslint/no-require-imports': ["error", { allow: ['^@silkweave\\/nestjs'] }], "prettier/prettier": ["error", { endOfLine: "auto" }], }, }, diff --git a/package-lock.json b/package-lock.json index 3cb8c96e..9989cc6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,9 @@ "@nestjs/swagger": "^11.4.4", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.0", + "@silkweave/core": "^2.6.0", + "@silkweave/mcp": "^2.6.0", + "@silkweave/nestjs": "^2.6.0", "archiver": "^8.0.0", "bullmq": "^5.78.1", "class-transformer": "^0.5.1", @@ -759,7 +762,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1265,7 +1267,6 @@ "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-8.0.0.tgz", "integrity": "sha512-GYXJNJclgm9H5Tt/tmuNWfjyF2BuygMwl8xrRIfxxOTgcK4SB8zNUPveTcHGAOoKKtPDVotHNZCBRrFCcOAXMA==", "license": "MIT", - "peer": true, "dependencies": { "redis-info": "^3.1.0" }, @@ -1304,11 +1305,38 @@ "resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-8.0.0.tgz", "integrity": "sha512-X/F256CmpBj9oj+fK2wOzjygkhTtjgWnc3f/SxPiUs3rQFvgYCplJLEaURh13J+Tpq46/1drAxq4Ukt4e5LelA==", "license": "MIT", - "peer": true, "dependencies": { "@bull-board/api": "8.0.0" } }, + "node_modules/@clack/core": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.1.tgz", + "integrity": "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.5.1.tgz", + "integrity": "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.4.1", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -1594,7 +1622,6 @@ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" @@ -1626,7 +1653,6 @@ "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", @@ -1640,6 +1666,18 @@ "node": ">=6" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2784,6 +2822,68 @@ "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", "license": "MIT" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "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.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", @@ -2894,7 +2994,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.4.tgz", "integrity": "sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "2.8.1" }, @@ -3049,7 +3148,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.27.tgz", "integrity": "sha512-kEGSzqM2lWr4whh4Ubflw+oPZSEzxvRMu9WL+LveZploJWTjec5bBlCiRVlVzTPg2kIwBiLwWSvCCW7Wnin1gg==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.4", "iterare": "1.2.1", @@ -3096,7 +3194,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.27.tgz", "integrity": "sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==", "license": "MIT", - "peer": true, "dependencies": { "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", @@ -3156,7 +3253,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.27.tgz", "integrity": "sha512-0ZFhz6H6EdGh4xQVbUNwjoAwBuz73P7FvUAl67h9CTdMqQlJDaQYJApBv8pKfVZ1fGjMCbl0m9DcC6pXaZPWSQ==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -3178,7 +3274,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.27.tgz", "integrity": "sha512-xgpLzaIDGOCC6xOAtHnRAz8sqieFgGxxu3MN5ID026Jt6oeL3efp29N5QHhPr7UlqBfy/Jd02uj0POkZq6Au3Q==", "license": "MIT", - "peer": true, "dependencies": { "socket.io": "4.8.3", "tslib": "2.8.1" @@ -3364,7 +3459,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.1.tgz", "integrity": "sha512-8rw/nKT0S+L+MkzgE9F2/mox7mAgsPlwfzmW9gsESN1lmQtIrVEfiiBwC2O8+guS1jBfQehJIdcdUj2OAp4VUQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/core": "^10.0.0 || ^11.0.0", @@ -3374,9 +3468,9 @@ } }, "node_modules/@nestjs/websockets": { - "version": "11.1.19", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.19.tgz", - "integrity": "sha512-2qo8jtIwwwgkqAI1BtnJ02EaFLrRkKA39eYXS8IhZCHilhBHCWdjnJ5cLcFq4oF+s+KZ7LcLGD/3stxJy8ijzg==", + "version": "11.1.27", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.27.tgz", + "integrity": "sha512-X3OgJt9KgYTvt9D7sNz9SOj3A1daAHy7DZrYhM1pky8Fh+erlKQH5IQ/tKm+GaJKA5M0srBUr1CMqjak/qNxOw==", "license": "MIT", "peer": true, "dependencies": { @@ -3458,6 +3552,12 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3580,6 +3680,110 @@ "hasInstallScript": true, "license": "Apache-2.0" }, + "node_modules/@silkweave/auth": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@silkweave/auth/-/auth-2.6.0.tgz", + "integrity": "sha512-6I3lEqG7EmVeFCXKAvY+0j7+4JGqCLNzvUy0FmiNupPCzJgYQHmy6XG//DyMfAppYuZw/EWr0/sg3vZC8Bv0tw==", + "license": "MIT", + "dependencies": { + "@silkweave/core": "2.6.0", + "jose": "^6.0.0" + } + }, + "node_modules/@silkweave/core": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@silkweave/core/-/core-2.6.0.tgz", + "integrity": "sha512-sF41f1TJL2jLZbDYyx6hLha47LbWTnoyXvNevBq2bywS+qweuuEYnqif4k4EGQex3r83uIIhTU4yeYrKtTbjqw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "@silkweave/logger": "2.6.0" + }, + "peerDependencies": { + "zod": "^3.25.0" + } + }, + "node_modules/@silkweave/logger": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@silkweave/logger/-/logger-2.6.0.tgz", + "integrity": "sha512-93evrg98clMVq88WziW8JtGKnwrBwbVgdkWQS5HcZjou0UcbECB67YqMc1+ZOaIB27UWJ6khQbPTL2EPZ4h7PQ==", + "license": "MIT", + "dependencies": { + "@clack/prompts": "^1.0.1", + "pino": "^9.6.0", + "zod": "^3.25.0" + } + }, + "node_modules/@silkweave/mcp": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@silkweave/mcp/-/mcp-2.6.0.tgz", + "integrity": "sha512-cwGYhUU94It/Nd9BBZtyHMp3foTQqDhby5jXTv8Vib8pJG55jtAIdW3I33uPnis9F54ICZ9PRDrrPr0XASb48g==", + "license": "MIT", + "dependencies": { + "@clack/prompts": "^1.0.1", + "@modelcontextprotocol/sdk": "^1.29.0", + "@silkweave/auth": "2.6.0", + "@silkweave/core": "2.6.0", + "@silkweave/logger": "2.6.0", + "change-case": "^5.4.4", + "commander": "^13.1.0", + "cors": "^2.8.6", + "express": "^5.2.1", + "zod": "^3.25.0" + } + }, + "node_modules/@silkweave/mcp/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@silkweave/nestjs": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@silkweave/nestjs/-/nestjs-2.6.0.tgz", + "integrity": "sha512-mTCT1NKSXulj368mstE/ALj06SMpyksgQjs03r1aoXEDhL7PwgkDmAnwG1UPH9KXxrnNWGlfWytLszDNkbwX8A==", + "license": "MIT", + "dependencies": { + "@silkweave/core": "2.6.0", + "cors": "^2.8.6", + "express": "^5.2.1", + "zod": "^3.25.0" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0", + "@nestjs/swagger": "^7.0.0 || ^8.0.0 || ^11.0.0", + "@silkweave/mcp": "^2.6.0", + "@silkweave/trpc": "^2.6.0", + "@silkweave/typegen": "^2.6.0", + "@trpc/server": "^11.7.1", + "class-validator": "^0.14.0 || ^0.15.0", + "rxjs": "^7.0.0" + }, + "peerDependenciesMeta": { + "@nestjs/swagger": { + "optional": true + }, + "@silkweave/mcp": { + "optional": true + }, + "@silkweave/trpc": { + "optional": true + }, + "@silkweave/typegen": { + "optional": true + }, + "@trpc/server": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.49", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", @@ -3939,7 +4143,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4059,7 +4262,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -4285,7 +4487,6 @@ "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", @@ -4999,7 +5200,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5089,7 +5289,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5105,7 +5304,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -5123,7 +5321,6 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -5140,7 +5337,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, "node_modules/ajv-keywords": { @@ -5510,6 +5706,15 @@ "dev": true, "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -5649,7 +5854,6 @@ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "license": "Apache-2.0", - "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -5952,7 +6156,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -6043,7 +6246,6 @@ "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.78.1.tgz", "integrity": "sha512-zD5IT+qMqbMgPFPdL9FwnZka1bz6nckM+5lXj4N0vsXqdzoVO6wizmXpwsg/4GnHmXJsL7XOKeWA64tYUdPrOA==", "license": "MIT", - "peer": true, "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.10.1", @@ -6306,6 +6508,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "license": "MIT" + }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -6339,7 +6547,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -6409,15 +6616,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.15.1", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz", "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -7266,8 +7471,7 @@ "version": "0.0.1581282", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dezalgo": { "version": "1.0.4", @@ -7766,7 +7970,6 @@ "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -7826,7 +8029,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -8081,6 +8283,27 @@ "bare-events": "^2.7.0" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -8192,6 +8415,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -8231,7 +8472,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==", - "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -8267,11 +8507,25 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, "funding": [ { "type": "github", @@ -8284,6 +8538,15 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fast-xml-builder": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", @@ -9083,6 +9346,15 @@ "url": "https://github.com/sponsors/EvanHahn" } }, + "node_modules/hono": { + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -9304,7 +9576,6 @@ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", "license": "MIT", - "peer": true, "dependencies": { "@ioredis/commands": "1.10.0", "cluster-key-slot": "1.1.1", @@ -9587,7 +9858,6 @@ "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", @@ -10314,6 +10584,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -10365,6 +10644,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -11513,6 +11798,7 @@ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 6" } @@ -11529,6 +11815,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -11892,7 +12187,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", @@ -11996,6 +12290,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -12006,6 +12337,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -12185,7 +12525,6 @@ "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12253,6 +12592,22 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -12649,6 +13004,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -12818,6 +13179,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -12852,8 +13222,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/require-directory": { "version": "2.1.1", @@ -12868,7 +13237,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==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13003,7 +13371,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -13028,6 +13395,15 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -13322,6 +13698,12 @@ "simple-concat": "^1.0.0" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -13455,6 +13837,15 @@ "node": ">= 10" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -13530,7 +13921,6 @@ "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", "hasInstallScript": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "bindings": "^1.5.0", "node-addon-api": "^7.0.0", @@ -14056,7 +14446,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -14210,6 +14599,15 @@ "b4a": "^1.6.4" } }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -14416,7 +14814,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -14601,7 +14998,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.30.tgz", "integrity": "sha512-8T35PzjefOdqc2ZR9mwLQj0pUGp6lQhMbK2EvVMwJVJWlaoHm0v/Q6dThNOZkFchD+0yMg8gwjKM28ePiLSXSQ==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -14821,7 +15217,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15153,7 +15548,6 @@ "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -15222,7 +15616,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -15771,6 +16164,15 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 7e9570c5..bd9fc22b 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,9 @@ "@nestjs/swagger": "^11.4.4", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.0", + "@silkweave/core": "^2.6.0", + "@silkweave/mcp": "^2.6.0", + "@silkweave/nestjs": "^2.6.0", "archiver": "^8.0.0", "bullmq": "^5.78.1", "class-transformer": "^0.5.1", @@ -110,6 +113,9 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "moduleNameMapper": { + "^@silkweave/nestjs$": "/../test/mocks/silkweave-nestjs.ts" + }, "collectCoverageFrom": [ "**/*.(t|j)s" ], diff --git a/src/app.module.ts b/src/app.module.ts index 4a86fb93..48bb2d8a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -29,6 +29,7 @@ import { CatalogModule } from './modules/catalog/catalog.module'; import { HooksModule } from './core/hooks'; import { PluginsModule } from './core/plugins'; import { PluginsApiModule } from './modules/plugins/plugins.module'; +import { ApiKeyGuard } from './modules/auth/guards/api-key.guard'; import { ExtensionsModule } from './plugins/extensions/extensions.module'; // Only import QueueModule if explicitly enabled to avoid Redis connection errors @@ -41,6 +42,24 @@ if (process.env.QUEUE_ENABLED === 'true') { queueModules.push(queueModule.QueueModule); } +// MCP is opt-in (off by default). Only when enabled do we load @silkweave/nestjs +// and register the MCP adapter; it reflects @Mcp()-decorated controller routes +// into MCP tools and mounts them at /mcp. `globalGuards: [ApiKeyGuard]` makes the +// existing global API-key auth run on tool calls (the app-global APP_GUARDs are not +// otherwise applied to Silkweave's raw routes). See @Mcp() usage in the controllers. +const mcpModules: Array = []; +if (process.env.MCP_ENABLED === 'true') { + const { SilkweaveModule } = require('@silkweave/nestjs') as typeof import('@silkweave/nestjs'); + const { mcp } = require('@silkweave/nestjs/mcp') as typeof import('@silkweave/nestjs/mcp'); + mcpModules.push( + SilkweaveModule.forRoot({ + silkweave: { name: 'openwa', description: 'OpenWA - self-hosted WhatsApp HTTP API', version: '0.2.3' }, + adapters: [mcp({ basePath: '/mcp' })], + globalGuards: [ApiKeyGuard], + }), + ); +} + @Module({ imports: [ // Configuration @@ -188,6 +207,7 @@ if (process.env.QUEUE_ENABLED === 'true') { CatalogModule, // Phase 3: Catalog API (WhatsApp Business) PluginsApiModule, // Phase 5: Plugins API ExtensionsModule, // First-party extension plugins (registered disabled) + ...mcpModules, // Opt-in MCP server (MCP_ENABLED=true) - additive, reflects @Mcp() routes ], }) export class AppModule {} diff --git a/src/modules/audit/audit.controller.ts b/src/modules/audit/audit.controller.ts index 39b7a874..7945da6b 100644 --- a/src/modules/audit/audit.controller.ts +++ b/src/modules/audit/audit.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; +import { Mcp } from '@silkweave/nestjs'; import { AuditService, AuditQueryOptions } from './audit.service'; import { AuditLog, AuditAction, AuditSeverity } from './entities/audit-log.entity'; @@ -20,6 +21,7 @@ export class AuditController { status: 200, description: 'Paginated list of audit logs', }) + @Mcp() async findAll( @Query('action') action?: AuditAction, @Query('severity') severity?: AuditSeverity, diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index dc157802..5445f583 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get, Post, Put, Delete, Body, Param, HttpCode, HttpStatus } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Mcp } from '@silkweave/nestjs'; import { AuthService } from './auth.service'; import { CreateApiKeyDto, UpdateApiKeyDto, ApiKeyResponseDto, ApiKeyCreatedResponseDto } from './dto'; import { RequireRole } from './decorators/auth.decorators'; @@ -18,6 +19,7 @@ export class AuthController { description: 'API key created', type: ApiKeyCreatedResponseDto, }) + @Mcp() async create(@Body() dto: CreateApiKeyDto): Promise { const { apiKey, rawKey } = await this.authService.createApiKey(dto); return { @@ -40,6 +42,7 @@ export class AuthController { @RequireRole(ApiKeyRole.ADMIN) @ApiOperation({ summary: 'List all API keys (admin only)' }) @ApiResponse({ status: 200, type: [ApiKeyResponseDto] }) + @Mcp() async findAll(): Promise { const keys = await this.authService.findAll(); return keys.map(k => ({ @@ -61,6 +64,7 @@ export class AuthController { @RequireRole(ApiKeyRole.ADMIN) @ApiOperation({ summary: 'Get API key details (admin only)' }) @ApiResponse({ status: 200, type: ApiKeyResponseDto }) + @Mcp() async findOne(@Param('id') id: string): Promise { const k = await this.authService.findOne(id); return { @@ -82,6 +86,7 @@ export class AuthController { @RequireRole(ApiKeyRole.ADMIN) @ApiOperation({ summary: 'Update API key (admin only)' }) @ApiResponse({ status: 200, type: ApiKeyResponseDto }) + @Mcp() async update(@Param('id') id: string, @Body() dto: UpdateApiKeyDto): Promise { const k = await this.authService.update(id, dto); return { @@ -104,6 +109,7 @@ export class AuthController { @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Delete API key (admin only)' }) @ApiResponse({ status: 204, description: 'API key deleted' }) + @Mcp() async delete(@Param('id') id: string): Promise { await this.authService.delete(id); } @@ -112,6 +118,7 @@ export class AuthController { @RequireRole(ApiKeyRole.ADMIN) @ApiOperation({ summary: 'Revoke API key (admin only)' }) @ApiResponse({ status: 200, type: ApiKeyResponseDto }) + @Mcp() async revoke(@Param('id') id: string): Promise { const k = await this.authService.revoke(id); return { diff --git a/src/modules/catalog/catalog.controller.ts b/src/modules/catalog/catalog.controller.ts index 521deb37..a0e4a956 100644 --- a/src/modules/catalog/catalog.controller.ts +++ b/src/modules/catalog/catalog.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get, Post, Param, Body, Query } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { Mcp } from '@silkweave/nestjs'; import { CatalogService } from './catalog.service'; import { SendProductDto, SendCatalogDto, ProductQueryDto } from './dto/send-product.dto'; import { RequireRole } from '../auth/decorators/auth.decorators'; @@ -12,18 +13,21 @@ export class CatalogController { @Get('catalog') @ApiOperation({ summary: 'Get business catalog info' }) + @Mcp() async getCatalog(@Param('sessionId') sessionId: string) { return this.catalogService.getCatalog(sessionId); } @Get('catalog/products') @ApiOperation({ summary: 'List catalog products' }) + @Mcp() async getProducts(@Param('sessionId') sessionId: string, @Query() query: ProductQueryDto) { return this.catalogService.getProducts(sessionId, query.page, query.limit); } @Get('catalog/products/:productId') @ApiOperation({ summary: 'Get a specific product' }) + @Mcp() async getProduct(@Param('sessionId') sessionId: string, @Param('productId') productId: string) { return this.catalogService.getProduct(sessionId, productId); } @@ -31,6 +35,7 @@ export class CatalogController { @Post('messages/send-product') @RequireRole(ApiKeyRole.OPERATOR) @ApiOperation({ summary: 'Send a product message' }) + @Mcp() async sendProduct(@Param('sessionId') sessionId: string, @Body() dto: SendProductDto) { return this.catalogService.sendProduct(sessionId, dto.chatId, dto.productId, dto.body); } @@ -38,6 +43,7 @@ export class CatalogController { @Post('messages/send-catalog') @RequireRole(ApiKeyRole.OPERATOR) @ApiOperation({ summary: 'Send catalog link' }) + @Mcp() async sendCatalog(@Param('sessionId') sessionId: string, @Body() dto: SendCatalogDto) { return this.catalogService.sendCatalog(sessionId, dto.chatId, dto.body); } diff --git a/src/modules/channel/channel.controller.ts b/src/modules/channel/channel.controller.ts index 7a33f58a..34a013bd 100644 --- a/src/modules/channel/channel.controller.ts +++ b/src/modules/channel/channel.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get, Post, Delete, Param, Body, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBody, ApiQuery } from '@nestjs/swagger'; +import { Mcp } from '@silkweave/nestjs'; import { ChannelService } from './channel.service'; import { SubscribeChannelDto } from './dto/subscribe-channel.dto'; import { RequireRole } from '../auth/decorators/auth.decorators'; @@ -18,6 +19,7 @@ export class ChannelController { description: 'List of subscribed channels', }) @ApiResponse({ status: 400, description: 'Session not ready' }) + @Mcp() async findAll(@Param('sessionId') sessionId: string) { return this.channelService.getSubscribedChannels(sessionId); } @@ -31,6 +33,7 @@ export class ChannelController { description: 'Channel details', }) @ApiResponse({ status: 404, description: 'Channel not found' }) + @Mcp() async findOne(@Param('sessionId') sessionId: string, @Param('channelId') channelId: string) { return this.channelService.getChannelById(sessionId, channelId); } @@ -44,6 +47,7 @@ export class ChannelController { status: 200, description: 'List of channel messages', }) + @Mcp() async getMessages( @Param('sessionId') sessionId: string, @Param('channelId') channelId: string, @@ -73,6 +77,7 @@ export class ChannelController { status: 201, description: 'Successfully subscribed to channel', }) + @Mcp() async subscribe(@Param('sessionId') sessionId: string, @Body() body: SubscribeChannelDto) { return this.channelService.subscribeToChannel(sessionId, body.inviteCode); } @@ -86,6 +91,7 @@ export class ChannelController { status: 200, description: 'Successfully unsubscribed from channel', }) + @Mcp() async unsubscribe(@Param('sessionId') sessionId: string, @Param('channelId') channelId: string) { await this.channelService.unsubscribeFromChannel(sessionId, channelId); return { success: true }; diff --git a/src/modules/contact/contact.controller.ts b/src/modules/contact/contact.controller.ts index 5fa5aeab..43ab0522 100644 --- a/src/modules/contact/contact.controller.ts +++ b/src/modules/contact/contact.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get, Post, Delete, Param, HttpCode, HttpStatus } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'; +import { Mcp } from '@silkweave/nestjs'; import { ContactService } from './contact.service'; import { RequireRole } from '../auth/decorators/auth.decorators'; import { ApiKeyRole } from '../auth/entities/api-key.entity'; @@ -18,6 +19,7 @@ export class ContactController { }) @ApiResponse({ status: 400, description: 'Session not ready' }) @ApiResponse({ status: 404, description: 'Session not found' }) + @Mcp() async findAll(@Param('sessionId') sessionId: string) { return this.contactService.getContacts(sessionId); } @@ -31,6 +33,7 @@ export class ContactController { description: 'Contact details', }) @ApiResponse({ status: 404, description: 'Contact not found' }) + @Mcp() async findOne(@Param('sessionId') sessionId: string, @Param('contactId') contactId: string) { return this.contactService.getContactById(sessionId, contactId); } @@ -43,6 +46,7 @@ export class ContactController { status: 200, description: 'Number existence check result', }) + @Mcp() async checkNumber(@Param('sessionId') sessionId: string, @Param('number') number: string) { // The engine returns the canonical chat id in its native format; we don't build the JID here // (decoupled from the whatsapp-web.js `@c.us` scheme). @@ -64,6 +68,7 @@ export class ContactController { status: 200, description: 'Profile picture URL', }) + @Mcp() async getProfilePicture(@Param('sessionId') sessionId: string, @Param('contactId') contactId: string) { const url = await this.contactService.getProfilePicture(sessionId, contactId); return { url }; @@ -77,6 +82,7 @@ export class ContactController { status: 200, description: 'Resolved phone number (MSISDN digits), or null when the engine cannot map it', }) + @Mcp() async resolvePhone(@Param('sessionId') sessionId: string, @Param('contactId') contactId: string) { const phone = await this.contactService.resolveContactPhone(sessionId, contactId); return { contactId, phone }; @@ -92,6 +98,7 @@ export class ContactController { status: 200, description: 'Contact blocked', }) + @Mcp() async blockContact(@Param('sessionId') sessionId: string, @Param('contactId') contactId: string) { await this.contactService.blockContact(sessionId, contactId); return { success: true, message: 'Contact blocked' }; @@ -106,6 +113,7 @@ export class ContactController { status: 200, description: 'Contact unblocked', }) + @Mcp() async unblockContact(@Param('sessionId') sessionId: string, @Param('contactId') contactId: string) { await this.contactService.unblockContact(sessionId, contactId); return { success: true, message: 'Contact unblocked' }; diff --git a/src/modules/group/group.controller.ts b/src/modules/group/group.controller.ts index cbebab24..038125de 100644 --- a/src/modules/group/group.controller.ts +++ b/src/modules/group/group.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get, Post, Put, Delete, Param, Body, HttpCode, HttpStatus } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBody } from '@nestjs/swagger'; +import { Mcp } from '@silkweave/nestjs'; import { GroupService } from './group.service'; import { CreateGroupDto, ParticipantsDto, GroupSubjectDto, GroupDescriptionDto } from './dto/group.dto'; import { RequireRole } from '../auth/decorators/auth.decorators'; @@ -14,6 +15,7 @@ export class GroupController { @ApiOperation({ summary: 'Get all groups for a session' }) @ApiParam({ name: 'sessionId', description: 'Session ID' }) @ApiResponse({ status: 200, description: 'List of groups' }) + @Mcp() async findAll(@Param('sessionId') sessionId: string) { return this.groupService.getGroups(sessionId); } @@ -24,6 +26,7 @@ export class GroupController { @ApiParam({ name: 'groupId', description: 'Group ID (e.g., 120363xxx@g.us)' }) @ApiResponse({ status: 200, description: 'Group details with participants' }) @ApiResponse({ status: 404, description: 'Group not found' }) + @Mcp() async findOne(@Param('sessionId') sessionId: string, @Param('groupId') groupId: string) { return this.groupService.getGroupInfo(sessionId, groupId); } @@ -34,6 +37,7 @@ export class GroupController { @ApiParam({ name: 'sessionId', description: 'Session ID' }) @ApiBody({ type: CreateGroupDto }) @ApiResponse({ status: 201, description: 'Group created' }) + @Mcp() async create(@Param('sessionId') sessionId: string, @Body() dto: CreateGroupDto) { return this.groupService.createGroup(sessionId, dto.name, dto.participants); } @@ -46,6 +50,7 @@ export class GroupController { @ApiBody({ type: ParticipantsDto }) @ApiResponse({ status: 200, description: 'Participants added' }) @HttpCode(HttpStatus.OK) + @Mcp() async addParticipants( @Param('sessionId') sessionId: string, @Param('groupId') groupId: string, @@ -62,6 +67,7 @@ export class GroupController { @ApiParam({ name: 'groupId', description: 'Group ID' }) @ApiBody({ type: ParticipantsDto }) @ApiResponse({ status: 200, description: 'Participants removed' }) + @Mcp() async removeParticipants( @Param('sessionId') sessionId: string, @Param('groupId') groupId: string, @@ -79,6 +85,7 @@ export class GroupController { @ApiBody({ type: ParticipantsDto }) @ApiResponse({ status: 200, description: 'Participants promoted' }) @HttpCode(HttpStatus.OK) + @Mcp() async promoteParticipants( @Param('sessionId') sessionId: string, @Param('groupId') groupId: string, @@ -96,6 +103,7 @@ export class GroupController { @ApiBody({ type: ParticipantsDto }) @ApiResponse({ status: 200, description: 'Participants demoted' }) @HttpCode(HttpStatus.OK) + @Mcp() async demoteParticipants( @Param('sessionId') sessionId: string, @Param('groupId') groupId: string, @@ -112,6 +120,7 @@ export class GroupController { @ApiParam({ name: 'groupId', description: 'Group ID' }) @ApiBody({ type: GroupSubjectDto }) @ApiResponse({ status: 200, description: 'Subject updated' }) + @Mcp() async setSubject( @Param('sessionId') sessionId: string, @Param('groupId') groupId: string, @@ -128,6 +137,7 @@ export class GroupController { @ApiParam({ name: 'groupId', description: 'Group ID' }) @ApiBody({ type: GroupDescriptionDto }) @ApiResponse({ status: 200, description: 'Description updated' }) + @Mcp() async setDescription( @Param('sessionId') sessionId: string, @Param('groupId') groupId: string, @@ -144,6 +154,7 @@ export class GroupController { @ApiParam({ name: 'groupId', description: 'Group ID' }) @ApiResponse({ status: 200, description: 'Left the group' }) @HttpCode(HttpStatus.OK) + @Mcp() async leave(@Param('sessionId') sessionId: string, @Param('groupId') groupId: string) { await this.groupService.leaveGroup(sessionId, groupId); return { success: true, message: 'Left the group' }; @@ -156,6 +167,7 @@ export class GroupController { @ApiParam({ name: 'sessionId', description: 'Session ID' }) @ApiParam({ name: 'groupId', description: 'Group ID' }) @ApiResponse({ status: 200, description: 'Group invite code' }) + @Mcp() async getInviteCode(@Param('sessionId') sessionId: string, @Param('groupId') groupId: string) { const inviteCode = await this.groupService.getGroupInviteCode(sessionId, groupId); return { @@ -171,6 +183,7 @@ export class GroupController { @ApiParam({ name: 'sessionId', description: 'Session ID' }) @ApiParam({ name: 'groupId', description: 'Group ID' }) @ApiResponse({ status: 200, description: 'New invite code generated' }) + @Mcp() async revokeInviteCode(@Param('sessionId') sessionId: string, @Param('groupId') groupId: string) { const newCode = await this.groupService.revokeGroupInviteCode(sessionId, groupId); return { diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts index 0bd97114..e2c868d1 100644 --- a/src/modules/health/health.controller.ts +++ b/src/modules/health/health.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, ServiceUnavailableException } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; +import { Mcp } from '@silkweave/nestjs'; import { Public } from '../auth/decorators/auth.decorators'; import { SkipThrottle } from '@nestjs/throttler'; import { ShutdownService } from '../../common/services/shutdown.service'; @@ -32,6 +33,7 @@ export class HealthController { @Get() @ApiOperation({ summary: 'Basic health check' }) @ApiResponse({ status: 200, description: 'Application is healthy' }) + @Mcp() check(): { status: string; timestamp: string } { return { status: 'ok', @@ -42,6 +44,7 @@ export class HealthController { @Get('live') @ApiOperation({ summary: 'Liveness probe for Kubernetes' }) @ApiResponse({ status: 200, description: 'Application is alive' }) + @Mcp() liveness(): { status: string } { // Liveness only reflects process liveness — deliberately static so a transient // dependency outage doesn't trigger a pod KILL (that's readiness' job). @@ -52,6 +55,7 @@ export class HealthController { @ApiOperation({ summary: 'Readiness probe — verifies the auth/audit + data databases respond' }) @ApiResponse({ status: 200, description: 'Application is ready to accept traffic' }) @ApiResponse({ status: 503, description: 'A required dependency is down' }) + @Mcp() async readiness(): Promise { // While draining (shutdown started), report 503 so the LB/orchestrator stops // routing new traffic before teardown — even if the DBs are still up. diff --git a/src/modules/infra/dto/import-data.dto.ts b/src/modules/infra/dto/import-data.dto.ts new file mode 100644 index 00000000..e1b0fc14 --- /dev/null +++ b/src/modules/infra/dto/import-data.dto.ts @@ -0,0 +1,54 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsArray, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; +import type { MessageBatchRow, MessageRow, SessionRow, WebhookRow } from './migration.types'; + +// Rows are inserted verbatim, so they are intentionally not element-validated +// (no @Type on the arrays): the migration blob is trusted admin input and we +// must not strip or coerce row fields on the way in. +class ImportTablesDto { + @ApiPropertyOptional({ type: 'array', description: 'Session rows' }) + @IsOptional() + @IsArray() + sessions?: SessionRow[]; + + @ApiPropertyOptional({ type: 'array', description: 'Webhook rows' }) + @IsOptional() + @IsArray() + webhooks?: WebhookRow[]; + + @ApiPropertyOptional({ type: 'array', description: 'Message rows' }) + @IsOptional() + @IsArray() + messages?: MessageRow[]; + + @ApiPropertyOptional({ type: 'array', description: 'Message batch rows' }) + @IsOptional() + @IsArray() + messageBatches?: MessageBatchRow[]; +} + +export class ImportDataDto { + @ApiProperty({ type: ImportTablesDto, description: 'Tables to import (replaces existing data)' }) + @IsObject() + @ValidateNested() + @Type(() => ImportTablesDto) + tables: ImportTablesDto; + + // Envelope fields produced by the export-data endpoint. Accepted and ignored so + // an exported blob can be posted back verbatim without tripping whitelist validation. + @ApiPropertyOptional({ description: 'Ignored; present on export-data output' }) + @IsOptional() + @IsString() + exportedAt?: string; + + @ApiPropertyOptional({ description: 'Ignored; present on export-data output' }) + @IsOptional() + @IsString() + dataDbType?: string; + + @ApiPropertyOptional({ description: 'Ignored; present on export-data output' }) + @IsOptional() + @IsObject() + counts?: Record; +} diff --git a/src/modules/infra/dto/import-storage.dto.ts b/src/modules/infra/dto/import-storage.dto.ts new file mode 100644 index 00000000..4d70e9a7 --- /dev/null +++ b/src/modules/infra/dto/import-storage.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class ImportStorageDto { + @ApiProperty({ + description: 'Path to the tar.gz archive to import. Must reference a file inside the app data directory.', + example: 'data/storage-export-1700000000000.tar.gz', + }) + @IsString() + @IsNotEmpty() + filePath: string; +} diff --git a/src/modules/infra/dto/index.ts b/src/modules/infra/dto/index.ts new file mode 100644 index 00000000..380e2647 --- /dev/null +++ b/src/modules/infra/dto/index.ts @@ -0,0 +1,5 @@ +export * from './save-config.dto'; +export * from './request-restart.dto'; +export * from './import-data.dto'; +export * from './import-storage.dto'; +export * from './migration.types'; diff --git a/src/modules/infra/dto/migration.types.ts b/src/modules/infra/dto/migration.types.ts new file mode 100644 index 00000000..b8f5ec61 --- /dev/null +++ b/src/modules/infra/dto/migration.types.ts @@ -0,0 +1,69 @@ +// Row shapes for the data-DB export/import migration blob. Shared by the +// infra controller (export) and ImportDataDto (import) so both agree on the +// structure without a circular import. + +export interface SessionRow { + id: string; + name: string; + status: string; + phone: string | null; + pushName: string | null; + config: string | Record; + proxyUrl: string | null; + proxyType: string | null; + connectedAt: string | null; + lastActiveAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface WebhookRow { + id: string; + sessionId: string; + url: string; + events: string | string[]; + secret: string | null; + headers: string | Record; + active: boolean; + retryCount: number; + lastTriggeredAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface MessageRow { + id: string; + sessionId: string; + messageId: string; + chatId: string; + direction: string; + type: string; + content: string | Record; + status: string; + metadata: string | Record; + createdAt: string; + updatedAt: string; +} + +export interface MessageBatchRow { + id: string; + batchId: string; + sessionId: string; + status: string; + messages: string | unknown[]; + options: string | Record; + progress: string | Record; + results: string | unknown[]; + currentIndex: number; + createdAt: string; + updatedAt: string; + startedAt: string | null; + completedAt: string | null; +} + +export interface MigrationTables { + sessions: SessionRow[]; + webhooks: WebhookRow[]; + messages: MessageRow[]; + messageBatches: MessageBatchRow[]; +} diff --git a/src/modules/infra/dto/request-restart.dto.ts b/src/modules/infra/dto/request-restart.dto.ts new file mode 100644 index 00000000..c36aac4c --- /dev/null +++ b/src/modules/infra/dto/request-restart.dto.ts @@ -0,0 +1,16 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsOptional, IsString } from 'class-validator'; + +export class RequestRestartDto { + @ApiPropertyOptional({ type: [String], description: 'Service profiles to (re)start, e.g. postgres, redis' }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + profiles?: string[]; + + @ApiPropertyOptional({ type: [String], description: 'Service profiles whose containers should be removed' }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + profilesToRemove?: string[]; +} diff --git a/src/modules/infra/dto/save-config.dto.ts b/src/modules/infra/dto/save-config.dto.ts new file mode 100644 index 00000000..f2577cb8 --- /dev/null +++ b/src/modules/infra/dto/save-config.dto.ts @@ -0,0 +1,182 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsBoolean, IsIn, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator'; + +class DatabaseConfigDto { + @ApiPropertyOptional({ enum: ['sqlite', 'postgres'] }) + @IsOptional() + @IsIn(['sqlite', 'postgres']) + type?: 'sqlite' | 'postgres'; + + @ApiPropertyOptional({ description: 'Use the bundled (container) database' }) + @IsOptional() + @IsBoolean() + builtIn?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + host?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + port?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + username?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + password?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + database?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + poolSize?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + sslEnabled?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + sslRejectUnauthorized?: boolean; +} + +class RedisConfigDto { + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + enabled?: boolean; + + @ApiPropertyOptional({ description: 'Use the bundled (container) Redis' }) + @IsOptional() + @IsBoolean() + builtIn?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + host?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + port?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + password?: string; +} + +class QueueConfigDto { + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + enabled?: boolean; +} + +class StorageConfigDto { + @ApiPropertyOptional({ enum: ['local', 's3'] }) + @IsOptional() + @IsIn(['local', 's3']) + type?: 'local' | 's3'; + + @ApiPropertyOptional({ description: 'Use the bundled (container) storage' }) + @IsOptional() + @IsBoolean() + builtIn?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + localPath?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + s3Bucket?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + s3Region?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + s3AccessKey?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + s3SecretKey?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + s3Endpoint?: string; +} + +class EngineConfigDto { + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + headless?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + sessionDataPath?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + browserArgs?: string; +} + +// Partial by design: the dashboard sends only the sections it renders and the +// controller merges them onto the existing generated env (#226). +export class SaveConfigDto { + @ApiPropertyOptional({ type: DatabaseConfigDto }) + @IsOptional() + @ValidateNested() + @Type(() => DatabaseConfigDto) + database?: DatabaseConfigDto; + + @ApiPropertyOptional({ type: RedisConfigDto }) + @IsOptional() + @ValidateNested() + @Type(() => RedisConfigDto) + redis?: RedisConfigDto; + + @ApiPropertyOptional({ type: QueueConfigDto }) + @IsOptional() + @ValidateNested() + @Type(() => QueueConfigDto) + queue?: QueueConfigDto; + + @ApiPropertyOptional({ type: StorageConfigDto }) + @IsOptional() + @ValidateNested() + @Type(() => StorageConfigDto) + storage?: StorageConfigDto; + + @ApiPropertyOptional({ type: EngineConfigDto }) + @IsOptional() + @ValidateNested() + @Type(() => EngineConfigDto) + engine?: EngineConfigDto; +} diff --git a/src/modules/infra/infra.controller.ts b/src/modules/infra/infra.controller.ts index 179f8cff..57176e19 100644 --- a/src/modules/infra/infra.controller.ts +++ b/src/modules/infra/infra.controller.ts @@ -1,5 +1,8 @@ import { Controller, Get, Put, Post, Body, BadRequestException } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Mcp } from '@silkweave/nestjs'; +import { SaveConfigDto, RequestRestartDto, ImportDataDto, ImportStorageDto } from './dto'; +import type { MigrationTables, SessionRow, WebhookRow, MessageRow, MessageBatchRow } from './dto'; import { ConfigService } from '@nestjs/config'; import { DataSource } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; @@ -28,113 +31,6 @@ interface InfraStatus { engine: { type: string; headless: boolean; sessionDataPath: string; browserArgs: string }; } -interface SaveConfigDto { - database?: { - type: 'sqlite' | 'postgres'; - builtIn?: boolean; - host?: string; - port?: string; - username?: string; - password?: string; - database?: string; - poolSize?: number; - sslEnabled?: boolean; - sslRejectUnauthorized?: boolean; - }; - redis?: { - enabled?: boolean; - builtIn?: boolean; - host?: string; - port?: string; - password?: string; - }; - queue?: { - enabled?: boolean; - }; - storage?: { - type: 'local' | 's3'; - builtIn?: boolean; - localPath?: string; - s3Bucket?: string; - s3Region?: string; - s3AccessKey?: string; - s3SecretKey?: string; - s3Endpoint?: string; - }; - engine?: { - headless?: boolean; - sessionDataPath?: string; - browserArgs?: string; - }; -} - -// Database migration types for export/import -interface SessionRow { - id: string; - name: string; - status: string; - phone: string | null; - pushName: string | null; - config: string | Record; - proxyUrl: string | null; - proxyType: string | null; - connectedAt: string | null; - lastActiveAt: string | null; - createdAt: string; - updatedAt: string; -} - -interface WebhookRow { - id: string; - sessionId: string; - url: string; - events: string | string[]; - secret: string | null; - headers: string | Record; - active: boolean; - retryCount: number; - lastTriggeredAt: string | null; - createdAt: string; - updatedAt: string; -} - -interface MessageRow { - id: string; - sessionId: string; - messageId: string; - chatId: string; - direction: string; - type: string; - content: string | Record; - status: string; - metadata: string | Record; - createdAt: string; - updatedAt: string; -} - -interface MessageBatchRow { - id: string; - batchId: string; - sessionId: string; - status: string; - messages: string | unknown[]; - options: string | Record; - progress: string | Record; - results: string | unknown[]; - currentIndex: number; - createdAt: string; - updatedAt: string; - startedAt: string | null; - completedAt: string | null; -} - -interface MigrationTables { - sessions: SessionRow[]; - webhooks: WebhookRow[]; - messages: MessageRow[]; - messageBatches: MessageBatchRow[]; -} - // Saved infrastructure config returned to the dashboard form for hydration. Secret // values are never echoed back — a `*Set` boolean indicates whether one is stored. interface SavedConfigResponse { @@ -186,6 +82,7 @@ export class InfraController { @RequireRole(ApiKeyRole.ADMIN) @ApiOperation({ summary: 'Get infrastructure status' }) @ApiResponse({ status: 200, description: 'Infrastructure status' }) + @Mcp() async getStatus(): Promise { // Check both database connections const mainDbConnected = this.mainDataSource.isInitialized; @@ -227,6 +124,7 @@ export class InfraController { @RequireRole(ApiKeyRole.ADMIN) @ApiOperation({ summary: 'Get available WhatsApp engines' }) @ApiResponse({ status: 200, description: 'List of available engines' }) + @Mcp() getEngines(): Array<{ id: string; name: string; enabled: boolean; features: string[] }> { return this.engineFactory.getAvailableEngines(); } @@ -235,6 +133,7 @@ export class InfraController { @RequireRole(ApiKeyRole.ADMIN) @ApiOperation({ summary: 'Get current active engine' }) @ApiResponse({ status: 200, description: 'Current engine info' }) + @Mcp() getCurrentEngine(): { engineType: string } { return { engineType: this.engineFactory.getCurrentEngine() }; } @@ -243,6 +142,7 @@ export class InfraController { @RequireRole(ApiKeyRole.ADMIN) @ApiOperation({ summary: 'Read the saved infrastructure configuration for the dashboard form' }) @ApiResponse({ status: 200, description: 'Saved configuration (secrets omitted)' }) + @Mcp() getConfig(): SavedConfigResponse { const envPath = path.resolve(process.cwd(), 'data', '.env.generated'); const saved: Record = fs.existsSync(envPath) ? dotenv.parse(fs.readFileSync(envPath, 'utf8')) : {}; @@ -292,7 +192,7 @@ export class InfraController { @RequireRole(ApiKeyRole.ADMIN) @ApiOperation({ summary: 'Save infrastructure configuration to .env file' }) @ApiResponse({ status: 200, description: 'Configuration saved' }) - @ApiBody({ description: 'Configuration to save' }) + @Mcp() saveConfig(@Body() config: SaveConfigDto): { message: string; saved: boolean; envPath: string; profiles: string[] } { try { const profiles: string[] = []; @@ -468,7 +368,8 @@ export class InfraController { @RequireRole(ApiKeyRole.ADMIN) @ApiOperation({ summary: 'Request server restart with Docker orchestration' }) @ApiResponse({ status: 200, description: 'Server will restart with new profiles' }) - async requestRestart(@Body() body?: { profiles?: string[]; profilesToRemove?: string[] }): Promise<{ + @Mcp() + async requestRestart(@Body() body?: RequestRestartDto): Promise<{ message: string; restarting: boolean; profiles: string[]; @@ -560,6 +461,7 @@ export class InfraController { @Public() @ApiOperation({ summary: 'Health check endpoint' }) @ApiResponse({ status: 200, description: 'Server is healthy' }) + @Mcp() healthCheck(): { status: string; timestamp: string } { return { status: 'ok', @@ -571,6 +473,7 @@ export class InfraController { @RequireRole(ApiKeyRole.ADMIN) @ApiOperation({ summary: 'Export all data from Data DB for migration' }) @ApiResponse({ status: 200, description: 'Exported data as JSON' }) + @Mcp() async exportData(): Promise<{ exportedAt: string; dataDbType: string; @@ -618,30 +521,9 @@ export class InfraController { @Post('import-data') @RequireRole(ApiKeyRole.ADMIN) @ApiOperation({ summary: 'Import data to Data DB (replaces existing data)' }) - @ApiBody({ - description: 'Exported data from export-data endpoint', - schema: { - type: 'object', - properties: { - tables: { - type: 'object', - properties: { - sessions: { type: 'array' }, - webhooks: { type: 'array' }, - messages: { type: 'array' }, - messageBatches: { type: 'array' }, - }, - }, - }, - }, - }) @ApiResponse({ status: 200, description: 'Data imported successfully' }) - async importData( - @Body() - data: { - tables: Partial; - }, - ): Promise<{ + @Mcp() + async importData(@Body() data: ImportDataDto): Promise<{ imported: boolean; counts: { sessions: number; webhooks: number; messages: number; messageBatches: number }; warnings: string[]; @@ -805,6 +687,7 @@ export class InfraController { @RequireRole(ApiKeyRole.ADMIN) @ApiOperation({ summary: 'Get file count in current storage' }) @ApiResponse({ status: 200, description: 'File count and size' }) + @Mcp() async getStorageFileCount(): Promise<{ storageType: string; count: number; @@ -824,6 +707,7 @@ export class InfraController { @RequireRole(ApiKeyRole.ADMIN) @ApiOperation({ summary: 'Export all storage files as tar.gz' }) @ApiResponse({ status: 200, description: 'Tar.gz archive stream' }) + @Mcp() async exportStorage(): Promise<{ message: string; download: string }> { // Note: In production, this would return a StreamableFile // For simplicity, we'll save to a temp file and return the path @@ -847,10 +731,10 @@ export class InfraController { @Post('storage/import') @RequireRole(ApiKeyRole.ADMIN) @ApiOperation({ summary: 'Import storage files from tar.gz' }) - @ApiBody({ description: 'Path to tar.gz file to import' }) @ApiResponse({ status: 200, description: 'Import result' }) + @Mcp() async importStorage( - @Body() body: { filePath: string }, + @Body() body: ImportStorageDto, ): Promise<{ imported: boolean; count: number; storageType: string }> { const { filePath } = body; diff --git a/src/modules/label/label.controller.ts b/src/modules/label/label.controller.ts index de3f3de7..aed56ccd 100644 --- a/src/modules/label/label.controller.ts +++ b/src/modules/label/label.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get, Post, Delete, Param, Body } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBody } from '@nestjs/swagger'; +import { Mcp } from '@silkweave/nestjs'; import { LabelService } from './label.service'; import { AddLabelDto } from './dto/add-label.dto'; import { RequireRole } from '../auth/decorators/auth.decorators'; @@ -19,6 +20,7 @@ export class LabelController { }) @ApiResponse({ status: 400, description: 'Session not ready or not a business account' }) @ApiResponse({ status: 404, description: 'Session not found' }) + @Mcp() async findAll(@Param('sessionId') sessionId: string) { return this.labelService.getLabels(sessionId); } @@ -32,6 +34,7 @@ export class LabelController { description: 'Label details', }) @ApiResponse({ status: 404, description: 'Label not found' }) + @Mcp() async findOne(@Param('sessionId') sessionId: string, @Param('labelId') labelId: string) { return this.labelService.getLabelById(sessionId, labelId); } @@ -44,6 +47,7 @@ export class LabelController { status: 200, description: 'List of labels for the chat', }) + @Mcp() async getChatLabels(@Param('sessionId') sessionId: string, @Param('chatId') chatId: string) { return this.labelService.getChatLabels(sessionId, chatId); } @@ -66,6 +70,7 @@ export class LabelController { status: 200, description: 'Label added to chat', }) + @Mcp() async addLabelToChat( @Param('sessionId') sessionId: string, @Param('chatId') chatId: string, @@ -85,6 +90,7 @@ export class LabelController { status: 200, description: 'Label removed from chat', }) + @Mcp() async removeLabelFromChat( @Param('sessionId') sessionId: string, @Param('chatId') chatId: string, diff --git a/src/modules/message/message.controller.ts b/src/modules/message/message.controller.ts index 4cf7fcb4..d6d33c2b 100644 --- a/src/modules/message/message.controller.ts +++ b/src/modules/message/message.controller.ts @@ -1,5 +1,6 @@ import { Controller, Post, Get, Param, Body, Query, HttpCode, HttpStatus } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; +import { Mcp } from '@silkweave/nestjs'; import { MessageService } from './message.service'; import { BulkMessageService } from './bulk-message.service'; import { SendTextMessageDto, SendMediaMessageDto, MessageResponseDto } from './dto'; @@ -34,6 +35,7 @@ export class MessageController { status: 200, description: 'Message history', }) + @Mcp() async getMessages( @Param('sessionId') sessionId: string, @Query('chatId') chatId?: string, @@ -61,6 +63,7 @@ export class MessageController { description: 'Session not active or invalid request', }) @ApiResponse({ status: 404, description: 'Session not found' }) + @Mcp() async sendText(@Param('sessionId') sessionId: string, @Body() dto: SendTextMessageDto): Promise { return this.messageService.sendText(sessionId, dto); } @@ -79,6 +82,7 @@ export class MessageController { description: 'Session not active or invalid request', }) @ApiResponse({ status: 404, description: 'Session or template not found' }) + @Mcp() async sendTemplate( @Param('sessionId') sessionId: string, @Body() dto: SendTemplateMessageDto, @@ -99,6 +103,7 @@ export class MessageController { status: 400, description: 'Session not active or invalid request', }) + @Mcp() async sendImage( @Param('sessionId') sessionId: string, @Body() dto: SendMediaMessageDto, @@ -119,6 +124,7 @@ export class MessageController { status: 400, description: 'Session not active or invalid request', }) + @Mcp() async sendVideo( @Param('sessionId') sessionId: string, @Body() dto: SendMediaMessageDto, @@ -139,6 +145,7 @@ export class MessageController { status: 400, description: 'Session not active or invalid request', }) + @Mcp() async sendAudio( @Param('sessionId') sessionId: string, @Body() dto: SendMediaMessageDto, @@ -159,6 +166,7 @@ export class MessageController { status: 400, description: 'Session not active or invalid request', }) + @Mcp() async sendDocument( @Param('sessionId') sessionId: string, @Body() dto: SendMediaMessageDto, @@ -177,6 +185,7 @@ export class MessageController { description: 'Location sent', type: MessageResponseDto, }) + @Mcp() async sendLocation(@Param('sessionId') sessionId: string, @Body() dto: SendLocationDto): Promise { return this.messageService.sendLocation(sessionId, dto); } @@ -190,6 +199,7 @@ export class MessageController { description: 'Contact sent', type: MessageResponseDto, }) + @Mcp() async sendContact(@Param('sessionId') sessionId: string, @Body() dto: SendContactDto): Promise { return this.messageService.sendContact(sessionId, dto); } @@ -203,6 +213,7 @@ export class MessageController { description: 'Sticker sent', type: MessageResponseDto, }) + @Mcp() async sendSticker( @Param('sessionId') sessionId: string, @Body() dto: SendMediaMessageDto, @@ -219,6 +230,7 @@ export class MessageController { description: 'Reply sent', type: MessageResponseDto, }) + @Mcp() async reply(@Param('sessionId') sessionId: string, @Body() dto: ReplyMessageDto): Promise { return this.messageService.reply(sessionId, dto); } @@ -232,6 +244,7 @@ export class MessageController { description: 'Message forwarded', type: MessageResponseDto, }) + @Mcp() async forward(@Param('sessionId') sessionId: string, @Body() dto: ForwardMessageDto): Promise { return this.messageService.forward(sessionId, dto); } @@ -251,6 +264,7 @@ export class MessageController { status: 400, description: 'Session not active or message not found', }) + @Mcp() async react(@Param('sessionId') sessionId: string, @Body() dto: ReactMessageDto): Promise<{ success: boolean }> { await this.messageService.reactToMessage(sessionId, dto); return { success: true }; @@ -273,6 +287,7 @@ export class MessageController { description: 'When true, downloads media (base64) for messages that have it. Slower; default false.', }) @ApiResponse({ status: 200, description: 'Chat history (most recent messages)' }) + @Mcp() async getChatHistory( @Param('sessionId') sessionId: string, @Param('chatId') chatId: string, @@ -299,6 +314,7 @@ export class MessageController { status: 200, description: 'List of reactions with senders', }) + @Mcp() async getReactions( @Param('sessionId') sessionId: string, @Param('chatId') chatId: string, @@ -322,6 +338,7 @@ export class MessageController { status: 400, description: 'Session not active or message not found', }) + @Mcp() async deleteMessage( @Param('sessionId') sessionId: string, @Body() dto: DeleteMessageDto, @@ -346,6 +363,7 @@ export class MessageController { status: 400, description: 'Session not active or invalid request', }) + @Mcp() async sendBulk( @Param('sessionId') sessionId: string, @Body() dto: SendBulkMessageDto, @@ -374,6 +392,7 @@ export class MessageController { status: 404, description: 'Batch not found', }) + @Mcp() async getBatchStatus(@Param('sessionId') sessionId: string, @Param('batchId') batchId: string) { const batch = await this.bulkMessageService.getBatchStatus(sessionId, batchId); return { @@ -404,6 +423,7 @@ export class MessageController { status: 404, description: 'Batch not found', }) + @Mcp() async cancelBatch(@Param('sessionId') sessionId: string, @Param('batchId') batchId: string) { const batch = await this.bulkMessageService.cancelBatch(sessionId, batchId); return { diff --git a/src/modules/plugins/plugins.controller.ts b/src/modules/plugins/plugins.controller.ts index 70f617a1..9cc58bc7 100644 --- a/src/modules/plugins/plugins.controller.ts +++ b/src/modules/plugins/plugins.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get, Post, Put, Param, Body, HttpCode, HttpStatus } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Mcp } from '@silkweave/nestjs'; import { PluginsService } from './plugins.service'; import { PluginDto, PluginConfigDto } from './dto/plugin.dto'; import { RequireRole } from '../auth/decorators/auth.decorators'; @@ -13,6 +14,7 @@ export class PluginsController { @Get() @ApiOperation({ summary: 'List all plugins' }) @ApiResponse({ status: 200, description: 'List of all plugins' }) + @Mcp() findAll(): PluginDto[] { return this.pluginsService.findAll(); } @@ -21,6 +23,7 @@ export class PluginsController { @ApiOperation({ summary: 'Get plugin by ID' }) @ApiResponse({ status: 200, description: 'Plugin details' }) @ApiResponse({ status: 404, description: 'Plugin not found' }) + @Mcp() findOne(@Param('id') id: string): PluginDto { return this.pluginsService.findOne(id); } @@ -30,6 +33,7 @@ export class PluginsController { @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Enable a plugin' }) @ApiResponse({ status: 200, description: 'Plugin enabled successfully' }) + @Mcp() async enable(@Param('id') id: string): Promise<{ success: boolean; message: string }> { return await this.pluginsService.enable(id); } @@ -39,6 +43,7 @@ export class PluginsController { @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Disable a plugin' }) @ApiResponse({ status: 200, description: 'Plugin disabled successfully' }) + @Mcp() async disable(@Param('id') id: string): Promise<{ success: boolean; message: string }> { return await this.pluginsService.disable(id); } @@ -47,6 +52,7 @@ export class PluginsController { @RequireRole(ApiKeyRole.ADMIN) @ApiOperation({ summary: 'Update plugin configuration' }) @ApiResponse({ status: 200, description: 'Plugin configuration updated' }) + @Mcp() updateConfig(@Param('id') id: string, @Body() configDto: PluginConfigDto): { success: boolean; message: string } { return this.pluginsService.updateConfig(id, configDto.config); } @@ -54,6 +60,7 @@ export class PluginsController { @Get(':id/health') @ApiOperation({ summary: 'Check plugin health' }) @ApiResponse({ status: 200, description: 'Plugin health status' }) + @Mcp() async healthCheck(@Param('id') id: string): Promise<{ healthy: boolean; message?: string }> { return await this.pluginsService.healthCheck(id); } diff --git a/src/modules/session/session.controller.ts b/src/modules/session/session.controller.ts index 062ac2e1..dd66be0f 100644 --- a/src/modules/session/session.controller.ts +++ b/src/modules/session/session.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get, Post, Delete, Param, Body, HttpCode, HttpStatus } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'; +import { Mcp } from '@silkweave/nestjs'; import { SessionService } from './session.service'; import { CreateSessionDto, @@ -51,6 +52,7 @@ export class SessionController { type: SessionResponseDto, }) @ApiResponse({ status: 409, description: 'Session name already exists' }) + @Mcp() async create(@Body() dto: CreateSessionDto): Promise { const session = await this.sessionService.create(dto); await this.auditService.logInfo(AuditAction.SESSION_CREATED, { @@ -67,6 +69,7 @@ export class SessionController { description: 'List of sessions', type: [SessionResponseDto], }) + @Mcp() async findAll(): Promise { const sessions = await this.sessionService.findAll(); return sessions.map(s => this.transformSession(s)); @@ -81,6 +84,7 @@ export class SessionController { type: SessionResponseDto, }) @ApiResponse({ status: 404, description: 'Session not found' }) + @Mcp() async findOne(@Param('id') id: string): Promise { const session = await this.sessionService.findOne(id); return this.transformSession(session); @@ -93,6 +97,7 @@ export class SessionController { @ApiParam({ name: 'id', description: 'Session ID' }) @ApiResponse({ status: 204, description: 'Session deleted' }) @ApiResponse({ status: 404, description: 'Session not found' }) + @Mcp() async delete(@Param('id') id: string): Promise { const session = await this.sessionService.findOne(id); await this.sessionService.delete(id); @@ -115,6 +120,7 @@ export class SessionController { }) @ApiResponse({ status: 400, description: 'Session already started' }) @ApiResponse({ status: 404, description: 'Session not found' }) + @Mcp() async start(@Param('id') id: string): Promise { const session = await this.sessionService.start(id); await this.auditService.logInfo(AuditAction.SESSION_STARTED, { @@ -134,6 +140,7 @@ export class SessionController { type: SessionResponseDto, }) @ApiResponse({ status: 404, description: 'Session not found' }) + @Mcp() async stop(@Param('id') id: string): Promise { const session = await this.sessionService.stop(id); await this.auditService.logInfo(AuditAction.SESSION_STOPPED, { @@ -157,6 +164,7 @@ export class SessionController { description: 'QR code not ready or session already authenticated', }) @ApiResponse({ status: 404, description: 'Session not found' }) + @Mcp() async getQRCode(@Param('id') id: string): Promise { const qrCode = await this.sessionService.getQRCode(id); await this.auditService.logInfo(AuditAction.SESSION_QR_GENERATED, { @@ -188,6 +196,7 @@ export class SessionController { }) @ApiResponse({ status: 400, description: 'Session not ready' }) @ApiResponse({ status: 404, description: 'Session not found' }) + @Mcp() async getGroups(@Param('id') id: string): Promise<{ id: string; name: string; linkedParentJID?: string | null }[]> { return this.sessionService.getGroups(id); } @@ -198,6 +207,7 @@ export class SessionController { @ApiResponse({ status: 200, description: 'List of active chats' }) @ApiResponse({ status: 400, description: 'Session not ready' }) @ApiResponse({ status: 404, description: 'Session not found' }) + @Mcp() async getChats(@Param('id') id: string): Promise { return this.sessionService.getChats(id); } @@ -209,11 +219,13 @@ export class SessionController { @ApiResponse({ status: 200, description: 'Chat marked as read successfully' }) @ApiResponse({ status: 400, description: 'Session not ready' }) @ApiResponse({ status: 404, description: 'Session not found' }) + @Mcp() async markChatRead(@Param('id') id: string, @Body() dto: MarkChatReadDto): Promise<{ success: boolean }> { const success = await this.sessionService.sendSeen(id, dto.chatId); return { success }; } + @Mcp() @Post(':id/chats/delete') @RequireRole(ApiKeyRole.OPERATOR) @ApiOperation({ summary: 'Delete a chat from the chat list (e.g. a group you have left)' }) @@ -226,6 +238,7 @@ export class SessionController { return { success }; } + @Mcp() @Post(':id/chats/typing') @RequireRole(ApiKeyRole.OPERATOR) @ApiOperation({ summary: "Send a typing/recording presence indicator to a chat (or clear it with 'paused')" }) @@ -245,6 +258,7 @@ export class SessionController { status: 200, description: 'Session statistics including counts and memory usage', }) + @Mcp() async getStats(): Promise<{ total: number; active: number; diff --git a/src/modules/settings/dto/update-settings.dto.ts b/src/modules/settings/dto/update-settings.dto.ts new file mode 100644 index 00000000..d93b8ab3 --- /dev/null +++ b/src/modules/settings/dto/update-settings.dto.ts @@ -0,0 +1,81 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsBoolean, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator'; + +class GeneralSettingsDto { + @ApiPropertyOptional({ description: 'Public base URL the API is reachable at' }) + @IsOptional() + @IsString() + apiBaseUrl?: string; + + @ApiPropertyOptional({ description: 'Session timeout in minutes' }) + @IsOptional() + @IsNumber() + sessionTimeout?: number; + + @ApiPropertyOptional({ description: 'Reconnect sessions automatically after a drop' }) + @IsOptional() + @IsBoolean() + autoReconnect?: boolean; + + @ApiPropertyOptional({ description: 'Enable verbose debug logging' }) + @IsOptional() + @IsBoolean() + debugMode?: boolean; +} + +class ApiSettingsDto { + @ApiPropertyOptional({ description: 'Requests allowed per rate-limit window' }) + @IsOptional() + @IsNumber() + rateLimit?: number; + + @ApiPropertyOptional({ description: 'Rate-limit window in milliseconds' }) + @IsOptional() + @IsNumber() + rateLimitWindow?: number; + + @ApiPropertyOptional({ description: 'Expose the Swagger docs endpoint' }) + @IsOptional() + @IsBoolean() + enableDocs?: boolean; +} + +class NotificationSettingsDto { + @ApiPropertyOptional({ description: 'Send notifications by email' }) + @IsOptional() + @IsBoolean() + emailEnabled?: boolean; + + @ApiPropertyOptional({ description: 'Address that receives notification emails' }) + @IsOptional() + @IsString() + notificationEmail?: string; + + @ApiPropertyOptional({ description: 'Alert on webhook delivery failures' }) + @IsOptional() + @IsBoolean() + webhookAlerts?: boolean; +} + +// Every section (and field within it) is optional: callers send only the parts +// they change and the controller merges them onto the current settings. +export class UpdateSettingsDto { + @ApiPropertyOptional({ type: GeneralSettingsDto }) + @IsOptional() + @ValidateNested() + @Type(() => GeneralSettingsDto) + general?: GeneralSettingsDto; + + @ApiPropertyOptional({ type: ApiSettingsDto }) + @IsOptional() + @ValidateNested() + @Type(() => ApiSettingsDto) + api?: ApiSettingsDto; + + @ApiPropertyOptional({ type: NotificationSettingsDto }) + @IsOptional() + @ValidateNested() + @Type(() => NotificationSettingsDto) + notifications?: NotificationSettingsDto; +} diff --git a/src/modules/settings/settings.controller.ts b/src/modules/settings/settings.controller.ts index d0c13cb9..dd3ea617 100644 --- a/src/modules/settings/settings.controller.ts +++ b/src/modules/settings/settings.controller.ts @@ -1,8 +1,10 @@ import { Controller, Get, Put, Body } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { ConfigService } from '@nestjs/config'; +import { Mcp } from '@silkweave/nestjs'; import { RequireRole } from '../auth/decorators/auth.decorators'; import { ApiKeyRole } from '../auth/entities/api-key.entity'; +import { UpdateSettingsDto } from './dto/update-settings.dto'; interface Settings { general: { @@ -55,6 +57,7 @@ export class SettingsController { @Get() @ApiOperation({ summary: 'Get application settings' }) @ApiResponse({ status: 200, description: 'Current settings' }) + @Mcp() get(): Settings { return this.settings; } @@ -63,7 +66,8 @@ export class SettingsController { @RequireRole(ApiKeyRole.ADMIN) @ApiOperation({ summary: 'Update application settings' }) @ApiResponse({ status: 200, description: 'Settings updated' }) - update(@Body() newSettings: Partial): Settings { + @Mcp() + update(@Body() newSettings: UpdateSettingsDto): Settings { if (newSettings.general) { this.settings.general = { ...this.settings.general, diff --git a/src/modules/stats/stats.controller.ts b/src/modules/stats/stats.controller.ts index edde5446..c676a031 100644 --- a/src/modules/stats/stats.controller.ts +++ b/src/modules/stats/stats.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get, Param, Query } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { Mcp } from '@silkweave/nestjs'; import { StatsService } from './stats.service'; import { StatsQueryDto } from './dto/stats-query.dto'; @@ -10,18 +11,21 @@ export class StatsController { @Get('overview') @ApiOperation({ summary: 'Get overall statistics' }) + @Mcp() async getOverview() { return this.statsService.getOverview(); } @Get('messages') @ApiOperation({ summary: 'Get message statistics with time series' }) + @Mcp() async getMessageStats(@Query() query: StatsQueryDto) { return this.statsService.getMessageStats(query.period || '24h'); } @Get('sessions/:sessionId') @ApiOperation({ summary: 'Get statistics for a specific session' }) + @Mcp() async getSessionStats(@Param('sessionId') sessionId: string) { return this.statsService.getSessionStats(sessionId); } diff --git a/src/modules/status/status.controller.ts b/src/modules/status/status.controller.ts index 7e529877..167caeed 100644 --- a/src/modules/status/status.controller.ts +++ b/src/modules/status/status.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get, Post, Delete, Param, Body } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { Mcp } from '@silkweave/nestjs'; import { StatusService } from './status.service'; import { SendTextStatusDto } from './dto/send-text-status.dto'; import { SendImageStatusDto, SendVideoStatusDto } from './dto/send-media-status.dto'; @@ -13,12 +14,14 @@ export class StatusController { @Get() @ApiOperation({ summary: 'Get all contact status updates' }) + @Mcp() async getStatuses(@Param('sessionId') sessionId: string) { return { statuses: await this.statusService.getStatuses(sessionId) }; } @Get(':contactId') @ApiOperation({ summary: 'Get status updates from a specific contact' }) + @Mcp() async getContactStatus(@Param('sessionId') sessionId: string, @Param('contactId') contactId: string) { return { statuses: await this.statusService.getContactStatus(sessionId, contactId) }; } @@ -26,6 +29,7 @@ export class StatusController { @Post('send-text') @RequireRole(ApiKeyRole.OPERATOR) @ApiOperation({ summary: 'Post a text status' }) + @Mcp() async sendTextStatus(@Param('sessionId') sessionId: string, @Body() dto: SendTextStatusDto) { return this.statusService.postTextStatus(sessionId, dto.text, { backgroundColor: dto.backgroundColor, @@ -36,6 +40,7 @@ export class StatusController { @Post('send-image') @RequireRole(ApiKeyRole.OPERATOR) @ApiOperation({ summary: 'Post an image status' }) + @Mcp() async sendImageStatus(@Param('sessionId') sessionId: string, @Body() dto: SendImageStatusDto) { return this.statusService.postImageStatus(sessionId, dto.image, dto.caption); } @@ -43,6 +48,7 @@ export class StatusController { @Post('send-video') @RequireRole(ApiKeyRole.OPERATOR) @ApiOperation({ summary: 'Post a video status' }) + @Mcp() async sendVideoStatus(@Param('sessionId') sessionId: string, @Body() dto: SendVideoStatusDto) { return this.statusService.postVideoStatus(sessionId, dto.video, dto.caption); } @@ -50,6 +56,7 @@ export class StatusController { @Delete(':statusId') @RequireRole(ApiKeyRole.OPERATOR) @ApiOperation({ summary: 'Delete own status' }) + @Mcp() async deleteStatus(@Param('sessionId') sessionId: string, @Param('statusId') statusId: string) { await this.statusService.deleteStatus(sessionId, statusId); return { message: 'Status deleted successfully' }; diff --git a/src/modules/template/template.controller.ts b/src/modules/template/template.controller.ts index 17e2a478..ca50adeb 100644 --- a/src/modules/template/template.controller.ts +++ b/src/modules/template/template.controller.ts @@ -5,6 +5,7 @@ import { CreateTemplateDto, UpdateTemplateDto, TemplateResponseDto } from './dto import { Template } from './entities/template.entity'; import { RequireRole } from '../auth/decorators/auth.decorators'; import { ApiKeyRole } from '../auth/entities/api-key.entity'; +import { Mcp } from '@silkweave/nestjs'; @ApiTags('templates') @Controller('sessions/:sessionId/templates') @@ -16,6 +17,7 @@ export class TemplateController { @ApiOperation({ summary: 'Create a message template for the session' }) @ApiParam({ name: 'sessionId', description: 'Session ID' }) @ApiResponse({ status: 201, description: 'Template created', type: TemplateResponseDto }) + @Mcp() async create(@Param('sessionId') sessionId: string, @Body() dto: CreateTemplateDto): Promise