diff --git a/package-lock.json b/package-lock.json index bca5eff..15c8ea8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "express-rate-limit": "^8.2.1", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", + "twilio": "^4.11.0", "winston": "^3.19.0" }, "devDependencies": { @@ -28,9 +29,12 @@ "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.3.0", + "@types/supertest": "^6.0.2", + "@types/twilio": "^3.19.3", "jest": "^30.2.0", "nodemon": "^3.1.14", "prisma": "^5.22.0", + "supertest": "^6.3.3", "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "typescript": "^5.9.3" @@ -87,6 +91,7 @@ "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", @@ -1191,6 +1196,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1478,6 +1493,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -1569,6 +1591,13 @@ "@types/node": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -1582,6 +1611,7 @@ "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1628,12 +1658,47 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/twilio": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/@types/twilio/-/twilio-3.19.3.tgz", + "integrity": "sha512-W53Z0TDCu6clZ5CzTWHRPnpQAad+AANglx6WiQ4Mkxxw21o4BYBx5EhkfR6J4iYqY58rtWB3r8kDGJ4y1uTUGQ==", + "deprecated": "This is a stub types definition. twilio provides its own type definitions, so you do not need this installed.", + "dev": true, + "license": "MIT", + "dependencies": { + "twilio": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -1966,6 +2031,18 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -2062,6 +2139,13 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2352,6 +2436,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2784,6 +2869,16 @@ "node": ">=20" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2838,6 +2933,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -2877,6 +2979,12 @@ "node": ">= 8" } }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2964,6 +3072,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -3241,6 +3360,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -3304,6 +3424,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -3472,6 +3599,22 @@ "node": ">= 0.6" } }, + "node_modules/formidable": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz", + "integrity": "sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0", + "qs": "^6.11.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3501,6 +3644,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3806,6 +3950,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -4195,6 +4352,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -5084,6 +5242,16 @@ "dev": true, "license": "MIT" }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5098,6 +5266,19 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -5588,6 +5769,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -5659,6 +5841,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -5736,6 +5924,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", @@ -5810,6 +6004,13 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scmp": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz", + "integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==", + "deprecated": "Just use Node.js's crypto.timingSafeEqual()", + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -6283,6 +6484,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", + "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==", + "deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.4", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.1.2", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=6.4.0 <13 || >=14" + } + }, + "node_modules/supertest": { + "version": "6.3.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz", + "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==", + "deprecated": "Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^8.1.2" + }, + "engines": { + "node": ">=6.4.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -6532,6 +6771,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -6578,6 +6818,25 @@ "license": "0BSD", "optional": true }, + "node_modules/twilio": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/twilio/-/twilio-4.23.0.tgz", + "integrity": "sha512-LdNBQfOe0dY2oJH2sAsrxazpgfFQo5yXGxe96QA8UWB5uu+433PrUbkv8gQ5RmrRCqUTPQ0aOrIyAdBr1aB03Q==", + "license": "MIT", + "dependencies": { + "axios": "^1.6.0", + "dayjs": "^1.11.9", + "https-proxy-agent": "^5.0.0", + "jsonwebtoken": "^9.0.0", + "qs": "^6.9.4", + "scmp": "^2.1.0", + "url-parse": "^1.5.9", + "xmlbuilder": "^13.0.2" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -6635,6 +6894,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6752,6 +7012,16 @@ "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", "license": "MIT" }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -7005,6 +7275,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/xmlbuilder": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz", + "integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index dd2466c..40defd3 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "express-rate-limit": "^8.2.1", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", + "twilio": "^4.11.0", "winston": "^3.19.0" }, "devDependencies": { @@ -45,9 +46,12 @@ "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.3.0", + "@types/twilio": "^3.19.3", "jest": "^30.2.0", "nodemon": "^3.1.14", "prisma": "^5.22.0", + "@types/supertest": "^6.0.2", + "supertest": "^6.3.3", "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "typescript": "^5.9.3" diff --git a/src/index.ts b/src/index.ts index e63973e..ed93da7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { connectDb } from './db' import { scheduleSessionCleanup } from './jobs/sessionCleanup' import healthRouter from './routes/health' import authRouter from './routes/auth' +import whatsappRouter from './routes/whatsapp' const app = express() @@ -18,6 +19,7 @@ const app = express() app.use(helmet()) app.use(cors()) app.use(express.json()) +app.use(express.urlencoded({ extended: false })) // Logging and rate limiting app.use(requestLogger) @@ -26,6 +28,7 @@ app.use(rateLimiter) // Public routes app.use('/health', healthRouter) app.use('/api/auth', authRouter) +app.use('/api/whatsapp', whatsappRouter) // Protected routes (require valid JWT) // All routes mounted below this line are automatically protected. diff --git a/src/routes/whatsapp.ts b/src/routes/whatsapp.ts new file mode 100644 index 0000000..98f2478 --- /dev/null +++ b/src/routes/whatsapp.ts @@ -0,0 +1,50 @@ +import express, { Request, Response } from 'express' +import { validateRequest, twiml } from 'twilio' +import { handleWhatsAppMessage } from '../whatsapp/handler' +import { config } from '../config/env' + +const router = express.Router() + +/** + * Health check for Twilio webhook + */ +router.get('/webhook', (_req: Request, res: Response) => { + res.status(200).send('WhatsApp webhook is alive') +}) + +/** + * Handles incoming WhatsApp messages from Twilio + * https://www.twilio.com/docs/usage/security#validating-requests + */ +router.post('/webhook', async (req: Request, res: Response) => { + const signature = req.header('x-twilio-signature') + const authToken = process.env.TWILIO_AUTH_TOKEN || '' + + const url = `${req.protocol}://${req.get('host')}${req.originalUrl}` + + if (!signature || !authToken) { + return res.status(403).send('Forbidden') + } + + const isValid = validateRequest(authToken, signature, url, req.body) + if (!isValid && config.nodeEnv === 'production') { + return res.status(403).send('Forbidden') + } + + const from = (req.body.From as string) || '' + const body = (req.body.Body as string) || '' + + try { + const response = await handleWhatsAppMessage(from, body) + const responseTwiml = new twiml.MessagingResponse() + responseTwiml.message(response.body) + res.type('text/xml').send(responseTwiml.toString()) + } catch (error) { + console.error('[WhatsApp webhook] error handling message:', error) + const errorTwiml = new twiml.MessagingResponse() + errorTwiml.message('Sorry, something went wrong processing your request.') + res.type('text/xml').send(errorTwiml.toString()) + } +}) + +export default router diff --git a/src/stellar/wallet.ts b/src/stellar/wallet.ts index 31c0aba..25128bf 100644 --- a/src/stellar/wallet.ts +++ b/src/stellar/wallet.ts @@ -1,9 +1,16 @@ import { Keypair } from '@stellar/stellar-sdk'; import * as crypto from 'crypto'; -const ENCRYPTION_KEY = process.env.WALLET_ENCRYPTION_KEY || ''; const ALGORITHM = 'aes-256-gcm'; +function getEncryptionKey(): string { + const key = process.env.WALLET_ENCRYPTION_KEY || ''; + if (!key || key.length !== 64) { + throw new Error('WALLET_ENCRYPTION_KEY must be 64 hex characters (32 bytes)'); + } + return key; +} + interface CustodialWallet { userId: string; publicKey: string; @@ -20,19 +27,15 @@ const walletStore = new Map(); * SECURITY: Never log secret keys. Use environment-based encryption key. */ function encryptSecret(secret: string): { encrypted: string; iv: string; authTag: string } { - if (!ENCRYPTION_KEY || ENCRYPTION_KEY.length !== 64) { - throw new Error('WALLET_ENCRYPTION_KEY must be 64 hex characters (32 bytes)'); - } - - const key = Buffer.from(ENCRYPTION_KEY, 'hex'); + const key = Buffer.from(getEncryptionKey(), 'hex'); const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv(ALGORITHM, key, iv); - + let encrypted = cipher.update(secret, 'utf8', 'hex'); encrypted += cipher.final('hex'); - + const authTag = cipher.getAuthTag(); - + return { encrypted, iv: iv.toString('hex'), @@ -44,23 +47,19 @@ function encryptSecret(secret: string): { encrypted: string; iv: string; authTag * Decrypt secret key */ function decryptSecret(encrypted: string, iv: string, authTag: string): string { - if (!ENCRYPTION_KEY || ENCRYPTION_KEY.length !== 64) { - throw new Error('WALLET_ENCRYPTION_KEY must be 64 hex characters (32 bytes)'); - } - - const key = Buffer.from(ENCRYPTION_KEY, 'hex'); + const key = Buffer.from(getEncryptionKey(), 'hex'); const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'hex')); decipher.setAuthTag(Buffer.from(authTag, 'hex')); - + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); decrypted += decipher.final('utf8'); - + return decrypted; } /** * Create custodial wallet for user - * + * * SECURITY NOTE: This is a custodial solution where the backend holds user keys. * Users trust the backend to secure their funds. Consider non-custodial alternatives * for production use cases requiring higher security guarantees. @@ -69,10 +68,10 @@ export async function createCustodialWallet(userId: string): Promise { const wallet = await getWalletByUserId(userId); - + if (!wallet) { throw new Error(`No wallet found for user ${userId}`); } - + const secret = decryptSecret(wallet.encryptedSecret, wallet.iv, wallet.authTag); return Keypair.fromSecret(secret); } @@ -114,4 +113,4 @@ export async function getKeypairForUser(userId: string): Promise { */ export function listWallets(): string[] { return Array.from(walletStore.values()).map(w => w.publicKey); -} +} \ No newline at end of file diff --git a/src/whatsapp/__tests__/whatsapp.test.ts b/src/whatsapp/__tests__/whatsapp.test.ts new file mode 100644 index 0000000..f8672f8 --- /dev/null +++ b/src/whatsapp/__tests__/whatsapp.test.ts @@ -0,0 +1,130 @@ +import express from 'express' +import request from 'supertest' +import crypto from 'crypto' + +import { clearUsersForTests, getUserForTests } from '../userManager' + +// Twilio signature helper (per https://www.twilio.com/docs/usage/security) +function computeTwilioSignature(authToken: string, url: string, params: Record) { + const keys = Object.keys(params).sort() + const data = [url, ...keys.map((k) => `${k}${params[k]}`)].join('') + return crypto.createHmac('sha1', authToken).update(data, 'utf8').digest('base64') +} + +function createApp() { + const app = express() + app.use(express.urlencoded({ extended: false })) + app.use(express.json()) + + // Ensure we load whatsapp router after env is set for production signature validation. + const { default: whatsappRouter } = require('../../routes/whatsapp') + app.use('/api/whatsapp', whatsappRouter) + + return app +} + +describe('WhatsApp webhook', () => { + const authToken = 'test-token' + const url = 'http://127.0.0.1/api/whatsapp/webhook' + + beforeEach(() => { + process.env.TWILIO_AUTH_TOKEN = authToken + process.env.NODE_ENV = 'production' + process.env.WALLET_ENCRYPTION_KEY = 'a'.repeat(64) + + // Required env vars for config/env.ts + process.env.STELLAR_NETWORK = 'TESTNET' + process.env.STELLAR_RPC_URL = 'https://example.com' + process.env.AGENT_SECRET_KEY = 'SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' + process.env.VAULT_CONTRACT_ID = 'vault-contract' + process.env.USDC_TOKEN_ADDRESS = 'usdc-token' + process.env.ANTHROPIC_API_KEY = 'test' + process.env.JWT_SEED = 'test-seed' + process.env.JWT_SESSION_TTL_HOURS = '24' + process.env.JWT_NONCE_TTL_MS = '300000' + process.env.JWT_CLEANUP_INTERVAL_MS = '86400000' + process.env.DATABASE_URL = 'postgresql://user:pass@localhost/db' + + clearUsersForTests() + }) + + it('rejects invalid Twilio signature in production', async () => { + const app = createApp() + + const res = await request(app) + .post('/api/whatsapp/webhook') .set('Host', '127.0.0.1') + .set('X-Forwarded-Proto', 'http') .set('X-Twilio-Signature', 'invalid') + .send({ From: 'whatsapp:+10000000000', Body: 'balance' }) + + expect(res.status).toBe(403) + }) + + it('accepts valid signature and sends OTP for new user', async () => { + const app = createApp() + const params = { From: 'whatsapp:+10000000000', Body: 'hello' } + const signature = computeTwilioSignature(authToken, url, params) + + const res = await request(app) + .post('/api/whatsapp/webhook') + .set('Host', '127.0.0.1') + .set('X-Forwarded-Proto', 'http') + .set('X-Twilio-Signature', signature) + .send(params) + + expect(res.status).toBe(200) + expect(res.text).toContain('') + expect(res.text).toContain('verification code') + + // Ensure user created and not verified + const user = getUserForTests('+10000000000') + expect(user).not.toBeNull() + expect(user?.verified).toBe(false) + }) + + it('verifies OTP and allows balance queries', async () => { + const app = createApp() + const from = 'whatsapp:+10000000000' + + // First message creates user and sends OTP + const firstParams = { From: from, Body: 'hello' } + const signature1 = computeTwilioSignature(authToken, url, firstParams) + const firstRes = await request(app) + .post('/api/whatsapp/webhook') + .set('Host', '127.0.0.1') + .set('X-Forwarded-Proto', 'http') + .set('X-Twilio-Signature', signature1) + .send(firstParams) + + expect(firstRes.status).toBe(200) + const otpMatch = firstRes.text.match(/(\d{6})/) + expect(otpMatch).not.toBeNull() + + const otp = otpMatch?.[1] || '' + + // Reply with OTP + const verifyParams = { From: from, Body: otp } + const signature2 = computeTwilioSignature(authToken, url, verifyParams) + const verifyRes = await request(app) + .post('/api/whatsapp/webhook') + .set('Host', '127.0.0.1') + .set('X-Forwarded-Proto', 'http') + .set('X-Twilio-Signature', signature2) + .send(verifyParams) + + expect(verifyRes.status).toBe(200) + expect(verifyRes.text).toContain('Your account is now verified') + + // Now ask for balance + const balanceParams = { From: from, Body: 'balance' } + const signature3 = computeTwilioSignature(authToken, url, balanceParams) + const balanceRes = await request(app) + .post('/api/whatsapp/webhook') + .set('Host', '127.0.0.1') + .set('X-Forwarded-Proto', 'http') + .set('X-Twilio-Signature', signature3) + .send(balanceParams) + + expect(balanceRes.status).toBe(200) + expect(balanceRes.text).toContain('Your current balance') + }) +}) diff --git a/src/whatsapp/handler.ts b/src/whatsapp/handler.ts new file mode 100644 index 0000000..a1858dd --- /dev/null +++ b/src/whatsapp/handler.ts @@ -0,0 +1,117 @@ +import { parseIntent } from '../nlp/parser' +import { normalizePhone, createOrGetUser, generateOtp, verifyOtp, getBalance, getUserWalletAddress, incrementBalance, decrementBalance } from './userManager' + +export type WhatsAppResponse = { + body: string +} + +function formatHelpMessage(): string { + return [ + 'Welcome to NeuroWealth! Here are some things you can ask me:', + '- "balance" → check your wallet balance', + '- "deposit " → get deposit instructions', + '- "withdraw " → withdraw funds (if available)', + '- "earnings" → see your performance', + '- "help" → show this message again', + ].join('\n') +} + +function formatOtpMessage(code: string): string { + return `Welcome to NeuroWealth! Your verification code is: ${code}\n\nReply with the 6-digit code to activate your account.` +} + +function formatBalanceMessage(balance: number, address: string): string { + return `Your current balance is ${balance.toFixed(2)} XLM.\nWallet: ${address}` +} + +function formatDepositInstruction(amount: number, address: string): string { + return `To deposit, send ${amount.toFixed(2)} XLM to your wallet address:\n${address}\n\nOnce the transaction is confirmed, reply "balance" to see your updated balance.` +} + +function formatWithdrawConfirmation(amount: number, newBalance: number): string { + return `Withdrawal request received for ${amount.toFixed(2)} XLM.\nYour new balance will be ${newBalance.toFixed(2)} XLM once processed.` +} + +function formatInsufficientFunds(balance: number, requested: number): string { + return `You only have ${balance.toFixed(2)} XLM available, but you requested ${requested.toFixed(2)} XLM.\nTry a smaller amount or deposit more funds.` +} + +function formatEarnings(balance: number): string { + const estimatedYield = balance * 0.04 // placeholder 4% APY + return `Your portfolio is earning ~4% APY.\nEstimated earnings next year: ${estimatedYield.toFixed(2)} XLM on current balance of ${balance.toFixed(2)} XLM.` +} + +function formatUnknownMessage(): string { + return `Sorry, I didn't understand that.\n${formatHelpMessage()}` +} + +function extractOtpCode(message: string): string | null { + const match = message.match(/\b(\d{6})\b/) + return match ? match[1] : null +} + +export async function handleWhatsAppMessage(from: string, message: string): Promise { + const normalizedPhone = normalizePhone(from) + const user = await createOrGetUser(normalizedPhone) + + // If user is not verified, treat any 6-digit code as an OTP attempt. + if (!user.verified) { + const codeFromMessage = extractOtpCode(message) + if (codeFromMessage) { + const success = verifyOtp(normalizedPhone, codeFromMessage) + if (success) { + const wallet = getUserWalletAddress(normalizedPhone) + return { + body: `✅ Your account is now verified!\nYour wallet address is: ${wallet}\n\n${formatHelpMessage()}`, + } + } + + return { + body: 'Invalid or expired OTP. Please request a new code by sending any message.', + } + } + + // Send OTP for new user or re-send if not verified + const otp = generateOtp(normalizedPhone) + return { body: formatOtpMessage(otp) } + } + + const intent = await parseIntent(message) + + switch (intent.action) { + case 'balance': { + const balance = getBalance(normalizedPhone) ?? 0 + const address = getUserWalletAddress(normalizedPhone) ?? 'unknown' + return { body: formatBalanceMessage(balance, address) } + } + + case 'deposit': { + const amount = intent.amount + if (!amount || amount <= 0) { + return { body: 'Please specify a deposit amount, e.g. "deposit 10".' } + } + const address = getUserWalletAddress(normalizedPhone) + return { body: formatDepositInstruction(amount, address ?? 'unknown') } + } + + case 'withdraw': { + const balance = getBalance(normalizedPhone) ?? 0 + const amount = intent.all ? balance : intent.amount + if (!amount || amount <= 0) { + return { body: 'Please specify a withdrawal amount, e.g. "withdraw 5" or "withdraw all".' } + } + if (amount > balance) { + return { body: formatInsufficientFunds(balance, amount) } + } + const newBalance = decrementBalance(normalizedPhone, amount) + return { body: formatWithdrawConfirmation(amount, newBalance) } + } + + case 'help': + return { body: formatHelpMessage() } + + case 'unknown': + default: + return { body: formatUnknownMessage() } + } +} diff --git a/src/whatsapp/userManager.ts b/src/whatsapp/userManager.ts new file mode 100644 index 0000000..7ac3fd0 --- /dev/null +++ b/src/whatsapp/userManager.ts @@ -0,0 +1,142 @@ +import crypto from 'crypto' +import { createCustodialWallet, getWalletByUserId } from '../stellar/wallet' + +export type WhatsAppUser = { + id: string + phone: string + verified: boolean + walletAddress: string + balance: number + otp?: { + code: string + expiresAt: number + } +} + +// In-memory user store (replace with DB in production) +const userStore = new Map() + +const OTP_TTL_MS = 1000 * 60 * 5 // 5 minutes + +/** + * Normalize WhatsApp phone identifiers (e.g. whatsapp:+1234567890) into a stable key. + */ +export function normalizePhone(phone: string): string { + return phone.replace(/^whatsapp:/i, '').trim() +} + +export function getUserByPhone(phone: string): WhatsAppUser | null { + const normalized = normalizePhone(phone) + return userStore.get(normalized) ?? null +} + +export async function createOrGetUser(phone: string): Promise { + const normalized = normalizePhone(phone) + const existing = userStore.get(normalized) + if (existing) { + return existing + } + + const userId = crypto.randomUUID() + const wallet = await createCustodialWallet(userId) + + const newUser: WhatsAppUser = { + id: userId, + phone: normalized, + verified: false, + walletAddress: wallet.publicKey, + balance: 0, + } + + userStore.set(normalized, newUser) + return newUser +} + +export function generateOtp(phone: string): string { + const user = getUserByPhone(phone) + if (!user) { + throw new Error('User not found') + } + + const code = (Math.floor(100000 + Math.random() * 900000)).toString() + user.otp = { + code, + expiresAt: Date.now() + OTP_TTL_MS, + } + + // Update store for reference + userStore.set(user.phone, user) + + return code +} + +export function verifyOtp(phone: string, code: string): boolean { + const user = getUserByPhone(phone) + if (!user || !user.otp) { + return false + } + + const now = Date.now() + if (now > user.otp.expiresAt) { + delete user.otp + userStore.set(user.phone, user) + return false + } + + if (user.otp.code !== code) { + return false + } + + user.verified = true + delete user.otp + userStore.set(user.phone, user) + return true +} + +export function getUserWalletAddress(phone: string): string | null { + const user = getUserByPhone(phone) + if (!user) return null + return user.walletAddress +} + +export function getBalance(phone: string): number | null { + const user = getUserByPhone(phone) + return user ? user.balance : null +} + +export function incrementBalance(phone: string, amount: number): number { + const user = getUserByPhone(phone) + if (!user) { + throw new Error('User not found') + } + user.balance = Math.max(0, user.balance + amount) + userStore.set(user.phone, user) + return user.balance +} + +export function decrementBalance(phone: string, amount: number): number { + const user = getUserByPhone(phone) + if (!user) { + throw new Error('User not found') + } + user.balance = Math.max(0, user.balance - amount) + userStore.set(user.phone, user) + return user.balance +} + +export function getUserForTests(phone: string): WhatsAppUser | null { + return getUserByPhone(phone) +} + +export function clearUsersForTests(): void { + userStore.clear() +} + +export async function ensureWalletDecrypted(phone: string) { + const user = getUserByPhone(phone) + if (!user) throw new Error('User not found') + + // Read from wallet store to ensure decryption works. + // This is used in tests to ensure secret keys are not stored in plaintext. + await getWalletByUserId(user.id) +}