start with phase 1
This commit is contained in:
16
.env.example
Normal file
16
.env.example
Normal file
@@ -0,0 +1,16 @@
|
||||
# Server
|
||||
PORT=3100
|
||||
|
||||
# Twilio
|
||||
TWILIO_ACCOUNT_SID=your_account_sid
|
||||
TWILIO_AUTH_TOKEN=your_auth_token
|
||||
TWILIO_PHONE_NUMBER=+1234567890
|
||||
|
||||
# Endorser API (for JWT validation)
|
||||
ENDORSER_API_URL=http://localhost:3000
|
||||
|
||||
# Database
|
||||
SMS_DB_PATH=./sms-service.sqlite3
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
.DS_Store
|
||||
*.sqlite3
|
||||
*.sqlite3-shm
|
||||
*.sqlite3-wal
|
||||
.idea
|
||||
44
README.md
44
README.md
@@ -0,0 +1,44 @@
|
||||
# SMS Notification Service
|
||||
|
||||
An SMS notification service for [Time Safari](https://timesafari.app) that handles phone number verification and scheduled SMS notifications (daily reminders, activity digests). It authenticates users by forwarding JWTs to an [endorser-ch](https://github.com/trentlarson/endorser-ch) server, stores verified phone numbers and notification preferences in SQLite, and sends messages via Twilio.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Deno](https://deno.land/) v2+
|
||||
- A Twilio account (optional for development — falls back to console logging)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# Development (auto-reload)
|
||||
deno task dev
|
||||
|
||||
# Production
|
||||
deno task start
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
deno task check # Type-check
|
||||
deno task test # Run tests
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
The service is a single Deno process with a SQLite database file. To deploy:
|
||||
|
||||
1. Copy the project to your server
|
||||
2. Set environment variables (see `.env.example`)
|
||||
3. Run `deno task start`
|
||||
|
||||
For process management, use systemd, Docker, or [Deno Deploy](https://deno.com/deploy).
|
||||
|
||||
The SQLite database is created automatically on first run and migrations are applied on startup.
|
||||
|
||||
22
deno.json
Normal file
22
deno.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"tasks": {
|
||||
"start": "deno run -A --env-file src/index.ts",
|
||||
"dev": "deno run -A --watch --env-file src/index.ts",
|
||||
"test": "deno test -A test/",
|
||||
"check": "deno check src/**/*.ts"
|
||||
},
|
||||
"imports": {
|
||||
"express": "npm:express@4",
|
||||
"cors": "npm:cors@2",
|
||||
"helmet": "npm:helmet@8",
|
||||
"pino": "npm:pino@9",
|
||||
"bcryptjs": "npm:bcryptjs@2",
|
||||
"twilio": "npm:twilio@5",
|
||||
"node-cron": "npm:node-cron@3",
|
||||
"@db/sqlite": "jsr:@db/sqlite@0.12",
|
||||
"@std/encoding": "jsr:@std/encoding"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
715
deno.lock
generated
Normal file
715
deno.lock
generated
Normal file
@@ -0,0 +1,715 @@
|
||||
{
|
||||
"version": "5",
|
||||
"specifiers": {
|
||||
"jsr:@db/sqlite@0.12": "0.12.0",
|
||||
"jsr:@denosaurs/plug@1": "1.1.0",
|
||||
"jsr:@std/assert@0.217": "0.217.0",
|
||||
"jsr:@std/encoding@*": "1.0.10",
|
||||
"jsr:@std/encoding@1": "1.0.10",
|
||||
"jsr:@std/fmt@1": "1.0.9",
|
||||
"jsr:@std/fs@1": "1.0.20",
|
||||
"jsr:@std/internal@^1.0.12": "1.0.12",
|
||||
"jsr:@std/path@0.217": "0.217.0",
|
||||
"jsr:@std/path@1": "1.1.3",
|
||||
"jsr:@std/path@^1.1.3": "1.1.3",
|
||||
"npm:bcryptjs@2": "2.4.3",
|
||||
"npm:cors@2": "2.8.6",
|
||||
"npm:express@4": "4.22.1",
|
||||
"npm:helmet@8": "8.1.0",
|
||||
"npm:node-cron@3": "3.0.3",
|
||||
"npm:pino@9": "9.14.0",
|
||||
"npm:twilio@5": "5.13.1"
|
||||
},
|
||||
"jsr": {
|
||||
"@db/sqlite@0.12.0": {
|
||||
"integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f",
|
||||
"dependencies": [
|
||||
"jsr:@denosaurs/plug",
|
||||
"jsr:@std/path@0.217"
|
||||
]
|
||||
},
|
||||
"@denosaurs/plug@1.1.0": {
|
||||
"integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044",
|
||||
"dependencies": [
|
||||
"jsr:@std/encoding@1",
|
||||
"jsr:@std/fmt",
|
||||
"jsr:@std/fs",
|
||||
"jsr:@std/path@1"
|
||||
]
|
||||
},
|
||||
"@std/assert@0.217.0": {
|
||||
"integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642"
|
||||
},
|
||||
"@std/encoding@1.0.10": {
|
||||
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
|
||||
},
|
||||
"@std/fmt@1.0.9": {
|
||||
"integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0"
|
||||
},
|
||||
"@std/fs@1.0.20": {
|
||||
"integrity": "e953206aae48d46ee65e8783ded459f23bec7dd1f3879512911c35e5484ea187",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal",
|
||||
"jsr:@std/path@^1.1.3"
|
||||
]
|
||||
},
|
||||
"@std/internal@1.0.12": {
|
||||
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
|
||||
},
|
||||
"@std/path@0.217.0": {
|
||||
"integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert"
|
||||
]
|
||||
},
|
||||
"@std/path@1.1.3": {
|
||||
"integrity": "b015962d82a5e6daea980c32b82d2c40142149639968549c649031a230b1afb3",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
"@pinojs/redact@0.4.0": {
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="
|
||||
},
|
||||
"accepts@1.3.8": {
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"dependencies": [
|
||||
"mime-types",
|
||||
"negotiator"
|
||||
]
|
||||
},
|
||||
"agent-base@6.0.2": {
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"dependencies": [
|
||||
"debug@4.4.3"
|
||||
]
|
||||
},
|
||||
"array-flatten@1.1.1": {
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
|
||||
},
|
||||
"asynckit@0.4.0": {
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"atomic-sleep@1.0.0": {
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="
|
||||
},
|
||||
"axios@1.14.0": {
|
||||
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
|
||||
"dependencies": [
|
||||
"follow-redirects",
|
||||
"form-data",
|
||||
"proxy-from-env"
|
||||
]
|
||||
},
|
||||
"bcryptjs@2.4.3": {
|
||||
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="
|
||||
},
|
||||
"body-parser@1.20.4": {
|
||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||
"dependencies": [
|
||||
"bytes",
|
||||
"content-type",
|
||||
"debug@2.6.9",
|
||||
"depd",
|
||||
"destroy",
|
||||
"http-errors",
|
||||
"iconv-lite",
|
||||
"on-finished",
|
||||
"qs",
|
||||
"raw-body",
|
||||
"type-is",
|
||||
"unpipe"
|
||||
]
|
||||
},
|
||||
"buffer-equal-constant-time@1.0.1": {
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
|
||||
},
|
||||
"bytes@3.1.2": {
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="
|
||||
},
|
||||
"call-bind-apply-helpers@1.0.2": {
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dependencies": [
|
||||
"es-errors",
|
||||
"function-bind"
|
||||
]
|
||||
},
|
||||
"call-bound@1.0.4": {
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"dependencies": [
|
||||
"call-bind-apply-helpers",
|
||||
"get-intrinsic"
|
||||
]
|
||||
},
|
||||
"combined-stream@1.0.8": {
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dependencies": [
|
||||
"delayed-stream"
|
||||
]
|
||||
},
|
||||
"content-disposition@0.5.4": {
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"dependencies": [
|
||||
"safe-buffer"
|
||||
]
|
||||
},
|
||||
"content-type@1.0.5": {
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="
|
||||
},
|
||||
"cookie-signature@1.0.7": {
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="
|
||||
},
|
||||
"cookie@0.7.2": {
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="
|
||||
},
|
||||
"cors@2.8.6": {
|
||||
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||
"dependencies": [
|
||||
"object-assign",
|
||||
"vary"
|
||||
]
|
||||
},
|
||||
"dayjs@1.11.20": {
|
||||
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="
|
||||
},
|
||||
"debug@2.6.9": {
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"dependencies": [
|
||||
"ms@2.0.0"
|
||||
]
|
||||
},
|
||||
"debug@4.4.3": {
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dependencies": [
|
||||
"ms@2.1.3"
|
||||
]
|
||||
},
|
||||
"delayed-stream@1.0.0": {
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
|
||||
},
|
||||
"depd@2.0.0": {
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="
|
||||
},
|
||||
"destroy@1.2.0": {
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="
|
||||
},
|
||||
"dunder-proto@1.0.1": {
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dependencies": [
|
||||
"call-bind-apply-helpers",
|
||||
"es-errors",
|
||||
"gopd"
|
||||
]
|
||||
},
|
||||
"ecdsa-sig-formatter@1.0.11": {
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"dependencies": [
|
||||
"safe-buffer"
|
||||
]
|
||||
},
|
||||
"ee-first@1.1.1": {
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
|
||||
},
|
||||
"encodeurl@2.0.0": {
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="
|
||||
},
|
||||
"es-define-property@1.0.1": {
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
|
||||
},
|
||||
"es-errors@1.3.0": {
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
|
||||
},
|
||||
"es-object-atoms@1.1.1": {
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"dependencies": [
|
||||
"es-errors"
|
||||
]
|
||||
},
|
||||
"es-set-tostringtag@2.1.0": {
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dependencies": [
|
||||
"es-errors",
|
||||
"get-intrinsic",
|
||||
"has-tostringtag",
|
||||
"hasown"
|
||||
]
|
||||
},
|
||||
"escape-html@1.0.3": {
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
|
||||
},
|
||||
"etag@1.8.1": {
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="
|
||||
},
|
||||
"express@4.22.1": {
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"dependencies": [
|
||||
"accepts",
|
||||
"array-flatten",
|
||||
"body-parser",
|
||||
"content-disposition",
|
||||
"content-type",
|
||||
"cookie",
|
||||
"cookie-signature",
|
||||
"debug@2.6.9",
|
||||
"depd",
|
||||
"encodeurl",
|
||||
"escape-html",
|
||||
"etag",
|
||||
"finalhandler",
|
||||
"fresh",
|
||||
"http-errors",
|
||||
"merge-descriptors",
|
||||
"methods",
|
||||
"on-finished",
|
||||
"parseurl",
|
||||
"path-to-regexp",
|
||||
"proxy-addr",
|
||||
"qs",
|
||||
"range-parser",
|
||||
"safe-buffer",
|
||||
"send",
|
||||
"serve-static",
|
||||
"setprototypeof",
|
||||
"statuses",
|
||||
"type-is",
|
||||
"utils-merge",
|
||||
"vary"
|
||||
]
|
||||
},
|
||||
"finalhandler@1.3.2": {
|
||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||
"dependencies": [
|
||||
"debug@2.6.9",
|
||||
"encodeurl",
|
||||
"escape-html",
|
||||
"on-finished",
|
||||
"parseurl",
|
||||
"statuses",
|
||||
"unpipe"
|
||||
]
|
||||
},
|
||||
"follow-redirects@1.15.11": {
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="
|
||||
},
|
||||
"form-data@4.0.5": {
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"dependencies": [
|
||||
"asynckit",
|
||||
"combined-stream",
|
||||
"es-set-tostringtag",
|
||||
"hasown",
|
||||
"mime-types"
|
||||
]
|
||||
},
|
||||
"forwarded@0.2.0": {
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="
|
||||
},
|
||||
"fresh@0.5.2": {
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="
|
||||
},
|
||||
"function-bind@1.1.2": {
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
|
||||
},
|
||||
"get-intrinsic@1.3.0": {
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"dependencies": [
|
||||
"call-bind-apply-helpers",
|
||||
"es-define-property",
|
||||
"es-errors",
|
||||
"es-object-atoms",
|
||||
"function-bind",
|
||||
"get-proto",
|
||||
"gopd",
|
||||
"has-symbols",
|
||||
"hasown",
|
||||
"math-intrinsics"
|
||||
]
|
||||
},
|
||||
"get-proto@1.0.1": {
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dependencies": [
|
||||
"dunder-proto",
|
||||
"es-object-atoms"
|
||||
]
|
||||
},
|
||||
"gopd@1.2.0": {
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
|
||||
},
|
||||
"has-symbols@1.1.0": {
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
|
||||
},
|
||||
"has-tostringtag@1.0.2": {
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dependencies": [
|
||||
"has-symbols"
|
||||
]
|
||||
},
|
||||
"hasown@2.0.2": {
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dependencies": [
|
||||
"function-bind"
|
||||
]
|
||||
},
|
||||
"helmet@8.1.0": {
|
||||
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg=="
|
||||
},
|
||||
"http-errors@2.0.1": {
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"dependencies": [
|
||||
"depd",
|
||||
"inherits",
|
||||
"setprototypeof",
|
||||
"statuses",
|
||||
"toidentifier"
|
||||
]
|
||||
},
|
||||
"https-proxy-agent@5.0.1": {
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"dependencies": [
|
||||
"agent-base",
|
||||
"debug@4.4.3"
|
||||
]
|
||||
},
|
||||
"iconv-lite@0.4.24": {
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"dependencies": [
|
||||
"safer-buffer"
|
||||
]
|
||||
},
|
||||
"inherits@2.0.4": {
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"ipaddr.js@1.9.1": {
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="
|
||||
},
|
||||
"jsonwebtoken@9.0.3": {
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
"dependencies": [
|
||||
"jws",
|
||||
"lodash.includes",
|
||||
"lodash.isboolean",
|
||||
"lodash.isinteger",
|
||||
"lodash.isnumber",
|
||||
"lodash.isplainobject",
|
||||
"lodash.isstring",
|
||||
"lodash.once",
|
||||
"ms@2.1.3",
|
||||
"semver"
|
||||
]
|
||||
},
|
||||
"jwa@2.0.1": {
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"dependencies": [
|
||||
"buffer-equal-constant-time",
|
||||
"ecdsa-sig-formatter",
|
||||
"safe-buffer"
|
||||
]
|
||||
},
|
||||
"jws@4.0.1": {
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"dependencies": [
|
||||
"jwa",
|
||||
"safe-buffer"
|
||||
]
|
||||
},
|
||||
"lodash.includes@4.3.0": {
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="
|
||||
},
|
||||
"lodash.isboolean@3.0.3": {
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="
|
||||
},
|
||||
"lodash.isinteger@4.0.4": {
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="
|
||||
},
|
||||
"lodash.isnumber@3.0.3": {
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="
|
||||
},
|
||||
"lodash.isplainobject@4.0.6": {
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="
|
||||
},
|
||||
"lodash.isstring@4.0.1": {
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="
|
||||
},
|
||||
"lodash.once@4.1.1": {
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
|
||||
},
|
||||
"math-intrinsics@1.1.0": {
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
|
||||
},
|
||||
"media-typer@0.3.0": {
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="
|
||||
},
|
||||
"merge-descriptors@1.0.3": {
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="
|
||||
},
|
||||
"methods@1.1.2": {
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="
|
||||
},
|
||||
"mime-db@1.52.0": {
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
|
||||
},
|
||||
"mime-types@2.1.35": {
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dependencies": [
|
||||
"mime-db"
|
||||
]
|
||||
},
|
||||
"mime@1.6.0": {
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"bin": true
|
||||
},
|
||||
"ms@2.0.0": {
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
||||
},
|
||||
"ms@2.1.3": {
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"negotiator@0.6.3": {
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="
|
||||
},
|
||||
"node-cron@3.0.3": {
|
||||
"integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
|
||||
"dependencies": [
|
||||
"uuid"
|
||||
]
|
||||
},
|
||||
"object-assign@4.1.1": {
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
|
||||
},
|
||||
"object-inspect@1.13.4": {
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="
|
||||
},
|
||||
"on-exit-leak-free@2.1.2": {
|
||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="
|
||||
},
|
||||
"on-finished@2.4.1": {
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"dependencies": [
|
||||
"ee-first"
|
||||
]
|
||||
},
|
||||
"parseurl@1.3.3": {
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="
|
||||
},
|
||||
"path-to-regexp@0.1.13": {
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="
|
||||
},
|
||||
"pino-abstract-transport@2.0.0": {
|
||||
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
|
||||
"dependencies": [
|
||||
"split2"
|
||||
]
|
||||
},
|
||||
"pino-std-serializers@7.1.0": {
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="
|
||||
},
|
||||
"pino@9.14.0": {
|
||||
"integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
|
||||
"dependencies": [
|
||||
"@pinojs/redact",
|
||||
"atomic-sleep",
|
||||
"on-exit-leak-free",
|
||||
"pino-abstract-transport",
|
||||
"pino-std-serializers",
|
||||
"process-warning",
|
||||
"quick-format-unescaped",
|
||||
"real-require",
|
||||
"safe-stable-stringify",
|
||||
"sonic-boom",
|
||||
"thread-stream"
|
||||
],
|
||||
"bin": true
|
||||
},
|
||||
"process-warning@5.0.0": {
|
||||
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="
|
||||
},
|
||||
"proxy-addr@2.0.7": {
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"dependencies": [
|
||||
"forwarded",
|
||||
"ipaddr.js"
|
||||
]
|
||||
},
|
||||
"proxy-from-env@2.1.0": {
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="
|
||||
},
|
||||
"qs@6.14.2": {
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"dependencies": [
|
||||
"side-channel"
|
||||
]
|
||||
},
|
||||
"quick-format-unescaped@4.0.4": {
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
|
||||
},
|
||||
"range-parser@1.2.1": {
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="
|
||||
},
|
||||
"raw-body@2.5.3": {
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"dependencies": [
|
||||
"bytes",
|
||||
"http-errors",
|
||||
"iconv-lite",
|
||||
"unpipe"
|
||||
]
|
||||
},
|
||||
"real-require@0.2.0": {
|
||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="
|
||||
},
|
||||
"safe-buffer@5.2.1": {
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
|
||||
},
|
||||
"safe-stable-stringify@2.5.0": {
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="
|
||||
},
|
||||
"safer-buffer@2.1.2": {
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
},
|
||||
"scmp@2.1.0": {
|
||||
"integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==",
|
||||
"deprecated": true
|
||||
},
|
||||
"semver@7.7.4": {
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"bin": true
|
||||
},
|
||||
"send@0.19.2": {
|
||||
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||
"dependencies": [
|
||||
"debug@2.6.9",
|
||||
"depd",
|
||||
"destroy",
|
||||
"encodeurl",
|
||||
"escape-html",
|
||||
"etag",
|
||||
"fresh",
|
||||
"http-errors",
|
||||
"mime",
|
||||
"ms@2.1.3",
|
||||
"on-finished",
|
||||
"range-parser",
|
||||
"statuses"
|
||||
]
|
||||
},
|
||||
"serve-static@1.16.3": {
|
||||
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||
"dependencies": [
|
||||
"encodeurl",
|
||||
"escape-html",
|
||||
"parseurl",
|
||||
"send"
|
||||
]
|
||||
},
|
||||
"setprototypeof@1.2.0": {
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
|
||||
},
|
||||
"side-channel-list@1.0.0": {
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"dependencies": [
|
||||
"es-errors",
|
||||
"object-inspect"
|
||||
]
|
||||
},
|
||||
"side-channel-map@1.0.1": {
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"dependencies": [
|
||||
"call-bound",
|
||||
"es-errors",
|
||||
"get-intrinsic",
|
||||
"object-inspect"
|
||||
]
|
||||
},
|
||||
"side-channel-weakmap@1.0.2": {
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"dependencies": [
|
||||
"call-bound",
|
||||
"es-errors",
|
||||
"get-intrinsic",
|
||||
"object-inspect",
|
||||
"side-channel-map"
|
||||
]
|
||||
},
|
||||
"side-channel@1.1.0": {
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"dependencies": [
|
||||
"es-errors",
|
||||
"object-inspect",
|
||||
"side-channel-list",
|
||||
"side-channel-map",
|
||||
"side-channel-weakmap"
|
||||
]
|
||||
},
|
||||
"sonic-boom@4.2.1": {
|
||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||
"dependencies": [
|
||||
"atomic-sleep"
|
||||
]
|
||||
},
|
||||
"split2@4.2.0": {
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="
|
||||
},
|
||||
"statuses@2.0.2": {
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="
|
||||
},
|
||||
"thread-stream@3.1.0": {
|
||||
"integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
|
||||
"dependencies": [
|
||||
"real-require"
|
||||
]
|
||||
},
|
||||
"toidentifier@1.0.1": {
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="
|
||||
},
|
||||
"twilio@5.13.1": {
|
||||
"integrity": "sha512-sT+PkhptF4Mf7t8eXFFvPQx4w5VHnBIPXbltGPMFRe+R2GxfRdMuFbuNA/cEm0aQR6LFQOn33+fhClg+TjRVqQ==",
|
||||
"dependencies": [
|
||||
"axios",
|
||||
"dayjs",
|
||||
"https-proxy-agent",
|
||||
"jsonwebtoken",
|
||||
"qs",
|
||||
"scmp",
|
||||
"xmlbuilder"
|
||||
]
|
||||
},
|
||||
"type-is@1.6.18": {
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"dependencies": [
|
||||
"media-typer",
|
||||
"mime-types"
|
||||
]
|
||||
},
|
||||
"unpipe@1.0.0": {
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="
|
||||
},
|
||||
"utils-merge@1.0.1": {
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="
|
||||
},
|
||||
"uuid@8.3.2": {
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"bin": true
|
||||
},
|
||||
"vary@1.1.2": {
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="
|
||||
},
|
||||
"xmlbuilder@13.0.2": {
|
||||
"integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ=="
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"dependencies": [
|
||||
"jsr:@db/sqlite@0.12",
|
||||
"jsr:@std/encoding@*",
|
||||
"npm:bcryptjs@2",
|
||||
"npm:cors@2",
|
||||
"npm:express@4",
|
||||
"npm:helmet@8",
|
||||
"npm:node-cron@3",
|
||||
"npm:pino@9",
|
||||
"npm:twilio@5"
|
||||
]
|
||||
}
|
||||
}
|
||||
49
sql/001_initial_schema.sql
Normal file
49
sql/001_initial_schema.sql
Normal file
@@ -0,0 +1,49 @@
|
||||
-- SMS User table
|
||||
CREATE TABLE sms_user (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
phone_number TEXT NOT NULL UNIQUE,
|
||||
issuer_did TEXT UNIQUE,
|
||||
pending_did TEXT,
|
||||
stored_jwt TEXT,
|
||||
jwt_expires_at TEXT,
|
||||
verified INTEGER NOT NULL DEFAULT 0,
|
||||
verification_code TEXT,
|
||||
verification_expiry TEXT,
|
||||
verification_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
codes_requested_this_hour INTEGER NOT NULL DEFAULT 0,
|
||||
codes_requested_hour_start TEXT,
|
||||
cooldown_until TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Notification preferences table
|
||||
CREATE TABLE sms_notification_pref (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES sms_user(id) ON DELETE CASCADE,
|
||||
notification_type TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
send_time_utc TEXT,
|
||||
timezone_offset INTEGER,
|
||||
reminder_message TEXT,
|
||||
alert_search_params TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, notification_type)
|
||||
);
|
||||
|
||||
-- Send log table
|
||||
CREATE TABLE sms_send_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES sms_user(id) ON DELETE CASCADE,
|
||||
notification_type TEXT NOT NULL,
|
||||
sent_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
status TEXT NOT NULL,
|
||||
provider_message_id TEXT,
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sms_user_did ON sms_user(issuer_did);
|
||||
CREATE INDEX idx_sms_notification_pref_user ON sms_notification_pref(user_id);
|
||||
CREATE INDEX idx_sms_send_log_user ON sms_send_log(user_id);
|
||||
CREATE INDEX idx_sms_send_log_sent_at ON sms_send_log(sent_at);
|
||||
76
src/api/controllers/jwt-router.ts
Normal file
76
src/api/controllers/jwt-router.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import express from "express";
|
||||
import { getDb } from "../../db.ts";
|
||||
import { config } from "../../common/config.ts";
|
||||
import l from "../../common/logger.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import { decodeBase64Url } from "@std/encoding/base64url";
|
||||
|
||||
class Controller {
|
||||
// deno-lint-ignore no-explicit-any
|
||||
async storeJwt(req: any, res: any): Promise<void> {
|
||||
try {
|
||||
const { jwt } = req.body;
|
||||
if (!jwt) {
|
||||
res.status(400).json({ error: { message: "jwt is required", code: "MISSING_FIELD" } });
|
||||
return;
|
||||
}
|
||||
|
||||
const issuerDid = res.locals.issuerDid;
|
||||
const db = getDb();
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
const user = db.prepare("SELECT * FROM sms_user WHERE issuer_did = ?").get(issuerDid) as any;
|
||||
if (!user) {
|
||||
res.status(400).json({ error: { message: "No phone registration found. Register first.", code: "NOT_REGISTERED" } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.verified) {
|
||||
res.status(400).json({ error: { message: "Phone not verified", code: "NOT_VERIFIED" } });
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${config.endorserApiUrl}/api/v2/report/rateLimits`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
res.status(400).json({ error: { message: "Provided JWT is not valid", code: "INVALID_JWT" } });
|
||||
return;
|
||||
}
|
||||
|
||||
let jwtExpiresAt: string | null = null;
|
||||
try {
|
||||
const payloadBytes = decodeBase64Url(jwt.split(".")[1]);
|
||||
const payload = JSON.parse(new TextDecoder().decode(payloadBytes));
|
||||
if (payload.exp) {
|
||||
jwtExpiresAt = new Date(payload.exp * 1000).toISOString();
|
||||
}
|
||||
} catch {
|
||||
// If we can't parse expiry, store without it
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
"UPDATE sms_user SET stored_jwt = ?, jwt_expires_at = ?, updated_at = ? WHERE id = ?"
|
||||
).run(jwt, jwtExpiresAt, now, user.id);
|
||||
|
||||
res.status(200).json({
|
||||
success: {
|
||||
message: "JWT stored",
|
||||
jwtExpiresAt,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
l.error("storeJwt error: %s", err);
|
||||
res.status(500).json({ error: { message: "Internal server error" } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new Controller();
|
||||
|
||||
const router = express.Router();
|
||||
router.put("/jwt", authMiddleware, controller.storeJwt);
|
||||
|
||||
export default router;
|
||||
90
src/api/controllers/registration-router.ts
Normal file
90
src/api/controllers/registration-router.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import express from "express";
|
||||
import registrationService from "../services/registration.service.ts";
|
||||
import { sendSms } from "../services/sms-provider.ts";
|
||||
import { getDb } from "../../db.ts";
|
||||
import l from "../../common/logger.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
class Controller {
|
||||
// deno-lint-ignore no-explicit-any
|
||||
async register(req: any, res: any): Promise<void> {
|
||||
try {
|
||||
const { phoneNumber } = req.body;
|
||||
if (!phoneNumber) {
|
||||
res.status(400).json({ error: { message: "phoneNumber is required", code: "MISSING_FIELD" } });
|
||||
return;
|
||||
}
|
||||
|
||||
const issuerDid = res.locals.issuerDid;
|
||||
const { code } = await registrationService.register(phoneNumber, issuerDid);
|
||||
|
||||
const db = getDb();
|
||||
const user = db.prepare("SELECT id FROM sms_user WHERE phone_number = ?").get(phoneNumber) as { id: number } | undefined;
|
||||
|
||||
await sendSms(
|
||||
phoneNumber,
|
||||
`Your Time Safari verification code is: ${code}. It expires in 15 minutes.`,
|
||||
user?.id,
|
||||
"verification"
|
||||
);
|
||||
|
||||
res.status(200).json({ success: { message: "Verification code sent" } });
|
||||
// deno-lint-ignore no-explicit-any
|
||||
} catch (err: any) {
|
||||
if (err.clientError) {
|
||||
res.status(400).json({ error: err.clientError });
|
||||
} else {
|
||||
l.error("register error: %s", err);
|
||||
res.status(500).json({ error: { message: "Internal server error" } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
verify(req: any, res: any): void {
|
||||
try {
|
||||
const { phoneNumber, code } = req.body;
|
||||
if (!phoneNumber || !code) {
|
||||
res.status(400).json({ error: { message: "phoneNumber and code are required", code: "MISSING_FIELD" } });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = registrationService.verify(phoneNumber, code);
|
||||
res.status(200).json({ success: result });
|
||||
// deno-lint-ignore no-explicit-any
|
||||
} catch (err: any) {
|
||||
if (err.clientError) {
|
||||
res.status(400).json({ error: err.clientError });
|
||||
} else {
|
||||
l.error("verify error: %s", err);
|
||||
res.status(500).json({ error: { message: "Internal server error" } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
unregister(_req: any, res: any): void {
|
||||
try {
|
||||
const issuerDid = res.locals.issuerDid;
|
||||
registrationService.unregister(issuerDid);
|
||||
res.status(200).json({ success: { message: "Registration removed" } });
|
||||
// deno-lint-ignore no-explicit-any
|
||||
} catch (err: any) {
|
||||
if (err.clientError) {
|
||||
res.status(400).json({ error: err.clientError });
|
||||
} else {
|
||||
l.error("unregister error: %s", err);
|
||||
res.status(500).json({ error: { message: "Internal server error" } });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new Controller();
|
||||
|
||||
const router = express.Router();
|
||||
router.post("/register", authMiddleware, controller.register);
|
||||
router.post("/verify", authMiddleware, controller.verify);
|
||||
router.delete("/register", authMiddleware, controller.unregister);
|
||||
|
||||
export default router;
|
||||
64
src/api/middleware/auth.ts
Normal file
64
src/api/middleware/auth.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { config } from "../../common/config.ts";
|
||||
import l from "../../common/logger.ts";
|
||||
|
||||
const BEARER_PREFIX = "Bearer ";
|
||||
|
||||
const didCache = new Map<string, { did: string; expiresAt: number }>();
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
|
||||
function simpleHash(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash |= 0;
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
export async function authMiddleware(req: any, res: any, next: any): Promise<void> {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith(BEARER_PREFIX)) {
|
||||
res.status(401).json({ error: { message: "Missing or invalid Authorization header" } });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(BEARER_PREFIX.length);
|
||||
const tokenHash = simpleHash(token);
|
||||
|
||||
const cached = didCache.get(tokenHash);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
res.locals.issuerDid = cached.did;
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.endorserApiUrl}/api/v2/report/rateLimits`, {
|
||||
headers: { Authorization: authHeader },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
l.info(`Auth rejected by endorser-ch: ${response.status}`);
|
||||
res.status(401).json({ error: { message: "Authentication failed" } });
|
||||
return;
|
||||
}
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
const data = (await response.json()) as any;
|
||||
const did = data.payload?.issuer || data.issuer;
|
||||
if (!did) {
|
||||
l.error("No DID found in endorser-ch response");
|
||||
res.status(401).json({ error: { message: "Could not extract DID from token" } });
|
||||
return;
|
||||
}
|
||||
|
||||
didCache.set(tokenHash, { did, expiresAt: Date.now() + CACHE_TTL_MS });
|
||||
|
||||
res.locals.issuerDid = did;
|
||||
next();
|
||||
} catch (err) {
|
||||
l.error("Error contacting endorser-ch for auth: %s", err);
|
||||
res.status(502).json({ error: { message: "Auth service unavailable" } });
|
||||
}
|
||||
}
|
||||
193
src/api/services/registration.service.ts
Normal file
193
src/api/services/registration.service.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { getDb } from "../../db.ts";
|
||||
import l from "../../common/logger.ts";
|
||||
|
||||
const E164_US_REGEX = /^\+1[2-9]\d{9}$/;
|
||||
const CODE_LENGTH = 6;
|
||||
const CODE_EXPIRY_MINUTES = 15;
|
||||
const MAX_CODES_PER_HOUR = 3;
|
||||
const MAX_VERIFY_ATTEMPTS = 5;
|
||||
const COOLDOWN_MINUTES = 60;
|
||||
const BCRYPT_ROUNDS = 10;
|
||||
|
||||
interface SmsUser {
|
||||
id: number;
|
||||
phone_number: string;
|
||||
issuer_did: string | null;
|
||||
pending_did: string | null;
|
||||
stored_jwt: string | null;
|
||||
jwt_expires_at: string | null;
|
||||
verified: number;
|
||||
verification_code: string | null;
|
||||
verification_expiry: string | null;
|
||||
verification_attempts: number;
|
||||
codes_requested_this_hour: number;
|
||||
codes_requested_hour_start: string | null;
|
||||
cooldown_until: string | null;
|
||||
}
|
||||
|
||||
export class RegistrationService {
|
||||
async register(
|
||||
phoneNumber: string,
|
||||
issuerDid: string
|
||||
): Promise<{ code: string }> {
|
||||
if (!E164_US_REGEX.test(phoneNumber)) {
|
||||
throw { clientError: { message: "Invalid phone number. Must be US E.164 format (+1XXXXXXXXXX).", code: "INVALID_PHONE" } };
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const now = new Date().toISOString();
|
||||
let user = db.prepare("SELECT * FROM sms_user WHERE phone_number = ?").get(phoneNumber) as SmsUser | undefined;
|
||||
|
||||
if (user) {
|
||||
if (user.cooldown_until && new Date(user.cooldown_until) > new Date()) {
|
||||
const remaining = Math.ceil(
|
||||
(new Date(user.cooldown_until).getTime() - Date.now()) / 60000
|
||||
);
|
||||
throw { clientError: { message: `Rate limited. Try again in ${remaining} minutes.`, code: "RATE_LIMITED" } };
|
||||
}
|
||||
|
||||
const hourStart = user.codes_requested_hour_start
|
||||
? new Date(user.codes_requested_hour_start)
|
||||
: null;
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
||||
|
||||
if (hourStart && hourStart > oneHourAgo) {
|
||||
if (user.codes_requested_this_hour >= MAX_CODES_PER_HOUR) {
|
||||
const cooldownUntil = new Date(
|
||||
Date.now() + COOLDOWN_MINUTES * 60 * 1000
|
||||
).toISOString();
|
||||
db.prepare("UPDATE sms_user SET cooldown_until = ?, updated_at = ? WHERE id = ?").run(
|
||||
cooldownUntil, now, user.id
|
||||
);
|
||||
throw { clientError: { message: `Too many codes requested. Try again in ${COOLDOWN_MINUTES} minutes.`, code: "RATE_LIMITED" } };
|
||||
}
|
||||
}
|
||||
|
||||
if (user.issuer_did && user.issuer_did !== issuerDid && user.verified) {
|
||||
db.prepare(
|
||||
"UPDATE sms_user SET pending_did = ?, verified = 0, updated_at = ? WHERE id = ?"
|
||||
).run(issuerDid, now, user.id);
|
||||
} else if (!user.issuer_did) {
|
||||
db.prepare("UPDATE sms_user SET issuer_did = ?, updated_at = ? WHERE id = ?").run(
|
||||
issuerDid, now, user.id
|
||||
);
|
||||
}
|
||||
} else {
|
||||
db.prepare(
|
||||
"INSERT INTO sms_user (phone_number, issuer_did, created_at, updated_at) VALUES (?, ?, ?, ?)"
|
||||
).run(phoneNumber, issuerDid, now, now);
|
||||
user = db.prepare("SELECT * FROM sms_user WHERE phone_number = ?").get(phoneNumber) as SmsUser;
|
||||
}
|
||||
|
||||
const code = generateCode();
|
||||
const hashedCode = bcrypt.hashSync(code, BCRYPT_ROUNDS);
|
||||
const expiry = new Date(
|
||||
Date.now() + CODE_EXPIRY_MINUTES * 60 * 1000
|
||||
).toISOString();
|
||||
|
||||
const hourStart = user!.codes_requested_hour_start
|
||||
? new Date(user!.codes_requested_hour_start)
|
||||
: null;
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
||||
const resetHour = !hourStart || hourStart <= oneHourAgo;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE sms_user SET
|
||||
verification_code = ?,
|
||||
verification_expiry = ?,
|
||||
verification_attempts = 0,
|
||||
codes_requested_this_hour = ?,
|
||||
codes_requested_hour_start = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
hashedCode,
|
||||
expiry,
|
||||
resetHour ? 1 : user!.codes_requested_this_hour + 1,
|
||||
resetHour ? now : user!.codes_requested_hour_start,
|
||||
now,
|
||||
user!.id
|
||||
);
|
||||
|
||||
l.info(`Verification code generated for phone ${phoneNumber.slice(-4)}`);
|
||||
return { code };
|
||||
}
|
||||
|
||||
verify(phoneNumber: string, code: string): { success: boolean } {
|
||||
const db = getDb();
|
||||
const now = new Date().toISOString();
|
||||
const user = db.prepare("SELECT * FROM sms_user WHERE phone_number = ?").get(phoneNumber) as SmsUser | undefined;
|
||||
|
||||
if (!user) {
|
||||
throw { clientError: { message: "Phone number not found", code: "NOT_FOUND" } };
|
||||
}
|
||||
|
||||
if (user.verified && !user.pending_did) {
|
||||
throw { clientError: { message: "Phone already verified", code: "ALREADY_VERIFIED" } };
|
||||
}
|
||||
|
||||
if (!user.verification_code || !user.verification_expiry) {
|
||||
throw { clientError: { message: "No verification pending", code: "NO_PENDING" } };
|
||||
}
|
||||
|
||||
if (user.verification_attempts >= MAX_VERIFY_ATTEMPTS) {
|
||||
throw { clientError: { message: "Too many attempts. Request a new code.", code: "TOO_MANY_ATTEMPTS" } };
|
||||
}
|
||||
|
||||
if (new Date(user.verification_expiry) < new Date()) {
|
||||
throw { clientError: { message: "Verification code expired", code: "CODE_EXPIRED" } };
|
||||
}
|
||||
|
||||
db.prepare("UPDATE sms_user SET verification_attempts = verification_attempts + 1, updated_at = ? WHERE id = ?").run(now, user.id);
|
||||
|
||||
const match = bcrypt.compareSync(code, user.verification_code);
|
||||
if (!match) {
|
||||
throw { clientError: { message: "Invalid verification code", code: "INVALID_CODE" } };
|
||||
}
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
const updateFields: Record<string, any> = {
|
||||
verified: 1,
|
||||
verification_code: null,
|
||||
verification_expiry: null,
|
||||
verification_attempts: 0,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
if (user.pending_did) {
|
||||
updateFields.issuer_did = user.pending_did;
|
||||
updateFields.pending_did = null;
|
||||
updateFields.stored_jwt = null;
|
||||
updateFields.jwt_expires_at = null;
|
||||
}
|
||||
|
||||
const setClauses = Object.keys(updateFields).map((k) => `${k} = ?`).join(", ");
|
||||
db.prepare(`UPDATE sms_user SET ${setClauses} WHERE id = ?`).run(
|
||||
...Object.values(updateFields),
|
||||
user.id
|
||||
);
|
||||
|
||||
l.info(`Phone ${phoneNumber.slice(-4)} verified`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
unregister(issuerDid: string): void {
|
||||
const db = getDb();
|
||||
const user = db.prepare("SELECT id FROM sms_user WHERE issuer_did = ?").get(issuerDid) as { id: number } | undefined;
|
||||
if (!user) {
|
||||
throw { clientError: { message: "No registration found", code: "NOT_FOUND" } };
|
||||
}
|
||||
db.prepare("DELETE FROM sms_user WHERE id = ?").run(user.id);
|
||||
l.info(`Unregistered DID ${issuerDid.slice(-8)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function generateCode(): string {
|
||||
const digits = Math.floor(Math.random() * 1_000_000)
|
||||
.toString()
|
||||
.padStart(CODE_LENGTH, "0");
|
||||
return digits;
|
||||
}
|
||||
|
||||
export default new RegistrationService();
|
||||
67
src/api/services/sms-provider.ts
Normal file
67
src/api/services/sms-provider.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import l from "../../common/logger.ts";
|
||||
import { config } from "../../common/config.ts";
|
||||
import { getDb } from "../../db.ts";
|
||||
|
||||
export interface SmsProvider {
|
||||
sendSms(to: string, body: string): Promise<{ messageId: string }>;
|
||||
}
|
||||
|
||||
class LogProvider implements SmsProvider {
|
||||
async sendSms(to: string, body: string): Promise<{ messageId: string }> {
|
||||
const messageId = `log-${Date.now()}`;
|
||||
l.info(`[SMS LOG] To: ${to} | Body: ${body} | ID: ${messageId}`);
|
||||
return { messageId };
|
||||
}
|
||||
}
|
||||
|
||||
async function createProvider(): Promise<SmsProvider> {
|
||||
if (config.twilioAccountSid && config.twilioAuthToken && config.twilioPhoneNumber) {
|
||||
l.info("Using Twilio SMS provider");
|
||||
const twilio = (await import("twilio")).default;
|
||||
const client = twilio(config.twilioAccountSid, config.twilioAuthToken);
|
||||
return {
|
||||
async sendSms(to: string, body: string) {
|
||||
const message = await client.messages.create({
|
||||
to,
|
||||
from: config.twilioPhoneNumber,
|
||||
body,
|
||||
});
|
||||
return { messageId: message.sid };
|
||||
},
|
||||
};
|
||||
}
|
||||
l.info("Using Log SMS provider (no Twilio credentials configured)");
|
||||
return new LogProvider();
|
||||
}
|
||||
|
||||
let provider: SmsProvider;
|
||||
|
||||
export async function initSmsProvider(): Promise<void> {
|
||||
provider = await createProvider();
|
||||
}
|
||||
|
||||
export async function sendSms(
|
||||
to: string,
|
||||
body: string,
|
||||
userId?: number,
|
||||
notificationType?: string
|
||||
): Promise<{ messageId: string }> {
|
||||
const db = getDb();
|
||||
try {
|
||||
const result = await provider.sendSms(to, body);
|
||||
if (userId) {
|
||||
db.prepare(
|
||||
"INSERT INTO sms_send_log (user_id, notification_type, status, provider_message_id) VALUES (?, ?, 'sent', ?)"
|
||||
).run(userId, notificationType || "verification", result.messageId);
|
||||
}
|
||||
return result;
|
||||
} catch (err: unknown) {
|
||||
if (userId) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
db.prepare(
|
||||
"INSERT INTO sms_send_log (user_id, notification_type, status, error_message) VALUES (?, ?, 'failed', ?)"
|
||||
).run(userId, notificationType || "verification", msg);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
8
src/common/config.ts
Normal file
8
src/common/config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const config = {
|
||||
port: parseInt(Deno.env.get("PORT") || "3100", 10),
|
||||
twilioAccountSid: Deno.env.get("TWILIO_ACCOUNT_SID") || "",
|
||||
twilioAuthToken: Deno.env.get("TWILIO_AUTH_TOKEN") || "",
|
||||
twilioPhoneNumber: Deno.env.get("TWILIO_PHONE_NUMBER") || "",
|
||||
endorserApiUrl: Deno.env.get("ENDORSER_API_URL") || "http://localhost:3000",
|
||||
smsDbPath: Deno.env.get("SMS_DB_PATH") || "./sms-service.sqlite3",
|
||||
};
|
||||
8
src/common/logger.ts
Normal file
8
src/common/logger.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import pino from "pino";
|
||||
|
||||
const l = pino({
|
||||
name: "sms-service",
|
||||
level: Deno.env.get("LOG_LEVEL") || "error",
|
||||
});
|
||||
|
||||
export default l;
|
||||
18
src/common/routes.ts
Normal file
18
src/common/routes.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import registrationRouter from "../api/controllers/registration-router.ts";
|
||||
import jwtRouter from "../api/controllers/jwt-router.ts";
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
export default function routes(app: any): void {
|
||||
// deno-lint-ignore no-explicit-any
|
||||
app.get("/api/sms/health", (_req: any, res: any) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
|
||||
app.use("/api/sms", registrationRouter);
|
||||
app.use("/api/sms", jwtRouter);
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
app.use("*", (_req: any, res: any) => {
|
||||
res.status(404).json({ error: { message: "Route not found" } });
|
||||
});
|
||||
}
|
||||
33
src/common/server.ts
Normal file
33
src/common/server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import helmet from "helmet";
|
||||
import l from "./logger.ts";
|
||||
|
||||
export default class Server {
|
||||
// deno-lint-ignore no-explicit-any
|
||||
private app: any;
|
||||
|
||||
constructor() {
|
||||
this.app = express();
|
||||
this.app.use(helmet());
|
||||
this.app.use(express.json({ limit: "40kb" }));
|
||||
this.app.use(express.urlencoded({ extended: true }));
|
||||
this.app.use(
|
||||
cors({
|
||||
allowedHeaders: ["Authorization", "Content-Type"],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
router(routeSetup: (app: any) => void): Server {
|
||||
routeSetup(this.app);
|
||||
return this;
|
||||
}
|
||||
|
||||
listen(port: number): void {
|
||||
this.app.listen(port, () => {
|
||||
l.info(`Server running on port ${port}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
61
src/db.ts
Normal file
61
src/db.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Database } from "@db/sqlite";
|
||||
import { config } from "./common/config.ts";
|
||||
import l from "./common/logger.ts";
|
||||
|
||||
let db: Database;
|
||||
|
||||
export function getDb(): Database {
|
||||
if (!db) {
|
||||
throw new Error("Database not initialized. Call initDb() first.");
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export function initDb(): void {
|
||||
db = new Database(config.smsDbPath);
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
runMigrations();
|
||||
}
|
||||
|
||||
function runMigrations(): void {
|
||||
const sqlDir = new URL("../sql", import.meta.url);
|
||||
let entries: Deno.DirEntry[];
|
||||
try {
|
||||
entries = [...Deno.readDirSync(sqlDir)];
|
||||
} catch {
|
||||
l.warn("No sql/ directory found, skipping migrations");
|
||||
return;
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
const applied = new Set(
|
||||
db.prepare("SELECT name FROM _migrations").all().map((r) => (r as Record<string, string>).name)
|
||||
);
|
||||
|
||||
const files = entries
|
||||
.filter((e) => e.isFile && e.name.endsWith(".sql"))
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
|
||||
for (const file of files) {
|
||||
if (applied.has(file)) continue;
|
||||
const filePath = new URL(file, sqlDir.href + "/");
|
||||
const sql = Deno.readTextFileSync(filePath);
|
||||
l.info(`Applying migration: ${file}`);
|
||||
db.exec(sql);
|
||||
db.prepare("INSERT INTO _migrations (name) VALUES (?)").run(file);
|
||||
}
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
if (db) {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
13
src/index.ts
Normal file
13
src/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import Server from "./common/server.ts";
|
||||
import routes from "./common/routes.ts";
|
||||
import { config } from "./common/config.ts";
|
||||
import { initDb } from "./db.ts";
|
||||
import { initSmsProvider } from "./api/services/sms-provider.ts";
|
||||
import l from "./common/logger.ts";
|
||||
|
||||
initDb();
|
||||
l.info("Database initialized");
|
||||
|
||||
await initSmsProvider();
|
||||
|
||||
new Server().router(routes).listen(config.port);
|
||||
Reference in New Issue
Block a user