diff --git a/PasswordlessLogin/manishgupta412/.env.example b/PasswordlessLogin/manishgupta412/.env.example new file mode 100644 index 000000000..d4d46513a --- /dev/null +++ b/PasswordlessLogin/manishgupta412/.env.example @@ -0,0 +1,22 @@ +# Mailtrap Email API — Transactional stream (Sending Domains → API Tokens) +MAILTRAP_TOKEN=your-mailtrap-api-token +# Must be an address on a domain verified in Mailtrap +MAIL_FROM_EMAIL=login@yourdomain.com +MAIL_FROM_NAME=Your App + +# Public base URL used to build magic links. https:// enables Secure cookies. +APP_URL=http://localhost:3000 +PORT=3000 + +# HMAC key for codes/tokens. Generate with: +# node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" +OTP_SECRET=replace-with-a-long-random-string-at-least-32-chars + +# false = only existing users (add with `npm run user:add -- you@example.com`) +# true = create the user on first successful login +AUTO_SIGNUP=false + +DATABASE_PATH=./data/app.db + +# Set to true only when running behind a reverse proxy that sets X-Forwarded-For +TRUST_PROXY=false diff --git a/PasswordlessLogin/manishgupta412/.gitignore b/PasswordlessLogin/manishgupta412/.gitignore new file mode 100644 index 000000000..f670aa6aa --- /dev/null +++ b/PasswordlessLogin/manishgupta412/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +data +.env +*.log diff --git a/PasswordlessLogin/manishgupta412/LICENSE b/PasswordlessLogin/manishgupta412/LICENSE new file mode 100644 index 000000000..d84f46326 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Manish Gupta + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PasswordlessLogin/manishgupta412/README.md b/PasswordlessLogin/manishgupta412/README.md new file mode 100644 index 000000000..00f67c22e --- /dev/null +++ b/PasswordlessLogin/manishgupta412/README.md @@ -0,0 +1,65 @@ +# Passwordless Login (Express, email code and magic link) + +A passwordless login for Node.js apps. Users enter their email and sign in with a **6-digit code** or a **magic link**. Codes and links are hashed, single-use and expire after 10 minutes, a code locks after 5 wrong attempts, and requests are rate limited. Login emails are sent with the Mailtrap Email API. + +![Signing in with a 6-digit code](docs/demo.gif) + +![Sign-in page](docs/demo-login.png) + +## Tech stack + +- **Language:** TypeScript (strict mode), Node.js 20 or newer +- **Server:** Express 5, with server-rendered HTML (no frontend framework) +- **Database:** SQLite through `better-sqlite3` +- **Validation:** Zod (environment variables and form input) +- **Email:** the official `mailtrap` SDK (Transactional stream) +- **Tests:** Vitest and Supertest, with a fake mailer and a fake clock + +## Run it locally + +```bash +cd PasswordlessLogin/manishgupta412 +npm install +cp .env.example .env +``` + +Fill in `.env` (see the next section), then: + +```bash +npm run user:add -- you@example.com # needed while AUTO_SIGNUP=false +npm run dev # http://localhost:3000/login +``` + +Run the tests (no Mailtrap account needed): + +```bash +npm test +npm run typecheck +``` + +## Getting the API key + +1. Create a free account at https://mailtrap.io. +2. Go to **Settings, API Tokens** and create a token with access to your sending domain. +3. Put it in `.env` as `MAILTRAP_TOKEN`. +4. Set `MAIL_FROM_EMAIL` to an address on a domain you verified in Mailtrap. For a first try, use `hello@demomailtrap.co` and sign in with your own Mailtrap account email, because the demo domain only delivers to the account owner. +5. Generate the HMAC key for `OTP_SECRET`: + + ```bash + node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" + ``` + +## Features + +- Choose a 6-digit code or a magic link on the login page +- Only the chosen credential is generated, emailed and stored, as an HMAC-SHA256 hash +- Single-use, 10-minute expiry, lock after 5 wrong codes, 60-second resend cooldown with a live timer +- Magic links only show a button on `GET`, so email scanners cannot use them up +- The same page, status and response time for known and unknown emails, so accounts cannot be enumerated +- Hashed server-side sessions, and logs that never contain codes, tokens or links + +## Live demo + +Not hosted, because it needs a Mailtrap account to send email. The GIF above is a recording of a local run. + +Full documentation, flow diagram and security notes: https://github.com/manishgupta412/express-email-otp-login diff --git a/PasswordlessLogin/manishgupta412/docs/demo-login.png b/PasswordlessLogin/manishgupta412/docs/demo-login.png new file mode 100644 index 000000000..2b4810878 Binary files /dev/null and b/PasswordlessLogin/manishgupta412/docs/demo-login.png differ diff --git a/PasswordlessLogin/manishgupta412/docs/demo.gif b/PasswordlessLogin/manishgupta412/docs/demo.gif new file mode 100644 index 000000000..cac585d2b Binary files /dev/null and b/PasswordlessLogin/manishgupta412/docs/demo.gif differ diff --git a/PasswordlessLogin/manishgupta412/package-lock.json b/PasswordlessLogin/manishgupta412/package-lock.json new file mode 100644 index 000000000..4ff06c231 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/package-lock.json @@ -0,0 +1,3880 @@ +{ + "name": "express-email-otp-login", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "express-email-otp-login", + "version": "1.0.0", + "dependencies": { + "better-sqlite3": "^12.11.1", + "dotenv": "^18.0.7", + "express": "^5.3.0", + "mailtrap": "^5.0.0", + "zod": "^4.6.7" + }, + "devDependencies": { + "@types/better-sqlite3": "^9.6.0", + "@types/express": "^5.0.6", + "@types/node": "^20.19.43", + "@types/supertest": "^7.2.1", + "supertest": "^7.3.1", + "tsx": "^4.23.15", + "typescript": "^7.0.2", + "vitest": "^4.1.11" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "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/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/better-sqlite3": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz", + "integrity": "sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@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/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "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/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/superagent": { + "version": "8.1.11", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz", + "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==", + "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": "7.2.1", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.1.tgz", + "integrity": "sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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/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/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "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/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/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/dotenv": { + "version": "18.0.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.7.tgz", + "integrity": "sha512-QEvMEF8ca3GNZJJnAbOgxpj5ULhBvnMcURp+XKrtvqFNPgjBvYoswQ0Jl2cO7JzCjxZjJhr9ZuIEhlrRxMft/Q==", + "license": "BSD-2-Clause", + "bin": { + "dotenv": "dist/index.cjs" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.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", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "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, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "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/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mailtrap": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mailtrap/-/mailtrap-5.0.0.tgz", + "integrity": "sha512-T2A6U9+kAGaSiDMDBjROhu+3tPcrg75UBFJmJdQjAkLCyJMR26I4fXD5J/5Ojc7CW8EXo4nQ8nxIuNkm9ft98Q==", + "license": "MIT", + "dependencies": { + "axios": ">=0.27", + "qs": "^6.15.0" + }, + "engines": { + "node": ">=16.20.1", + "yarn": ">=1.22.17" + }, + "peerDependencies": { + "@types/nodemailer": "^6.4.9", + "nodemailer": "^9.0.1 || ^10.0.0" + }, + "peerDependenciesMeta": { + "@types/nodemailer": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/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", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-abi": { + "version": "3.98.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.98.0.tgz", + "integrity": "sha512-Llh8noNw7s15m+rmIrZnZQbe4gLyasL9loT74rC92PkmwWKeEedKvc7o6pjp8B3MhrYT/Cl846NOYZbz1wmddw==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superagent": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.4.1.tgz", + "integrity": "sha512-PVMkMrhKrSTtFhUU8jiWuGh/gyRMKiyCTCmm0+qVzRNxlfntmRKJkDhlEXN62x3HIb33sQ4kmL8lcSdccWAchQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.3.1.tgz", + "integrity": "sha512-vNaNCFoCR/edDbErQZp5KLNT8R4dSPHX7rlDLoE8hWLAm/878j6ShMbe8HK/E6RwwuhlSXVelXAIg9AxR5WWPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/PasswordlessLogin/manishgupta412/package.json b/PasswordlessLogin/manishgupta412/package.json new file mode 100644 index 000000000..bf51363e3 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/package.json @@ -0,0 +1,55 @@ +{ + "name": "express-email-otp-login", + "version": "1.0.0", + "description": "Passwordless login for Express with email one-time codes and magic links, delivered through the Mailtrap Email API.", + "keywords": [ + "express", + "nodejs", + "typescript", + "passwordless", + "passwordless-login", + "otp", + "email-otp", + "magic-link", + "authentication", + "transactional-email", + "mailtrap", + "email-api" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/manishgupta412/express-email-otp-login.git" + }, + "homepage": "https://github.com/manishgupta412/express-email-otp-login#readme", + "private": true, + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc -p tsconfig.build.json", + "start": "node dist/server.js", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit", + "user:add": "tsx src/cli/addUser.ts" + }, + "dependencies": { + "better-sqlite3": "^12.11.1", + "dotenv": "^18.0.7", + "express": "^5.3.0", + "mailtrap": "^5.0.0", + "zod": "^4.6.7" + }, + "devDependencies": { + "@types/better-sqlite3": "^9.6.0", + "@types/express": "^5.0.6", + "@types/node": "^20.19.43", + "@types/supertest": "^7.2.1", + "supertest": "^7.3.1", + "tsx": "^4.23.15", + "typescript": "^7.0.2", + "vitest": "^4.1.11" + } +} diff --git a/PasswordlessLogin/manishgupta412/src/app.ts b/PasswordlessLogin/manishgupta412/src/app.ts new file mode 100644 index 000000000..1769c77e9 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/app.ts @@ -0,0 +1,108 @@ +import express, { type ErrorRequestHandler, type Express } from "express"; +import { ChallengeStore } from "./auth/challenges.js"; +import { RateLimiter } from "./auth/rateLimit.js"; +import { SessionStore } from "./auth/sessions.js"; +import { UserStore } from "./auth/users.js"; +import { systemClock, type Clock } from "./clock.js"; +import type { Config } from "./config.js"; +import type { DB } from "./db.js"; +import type { Mailer } from "./email/mailer.js"; +import { createLogger, type Logger } from "./logger.js"; +import { dashboardRouter } from "./routes/dashboard.js"; +import { loginRouter } from "./routes/login.js"; +import { errorPage } from "./views/auth.js"; +import { CLIENT_JS } from "./views/client.js"; + +export type AppConfig = Pick; + +export interface AppDeps { + config: AppConfig; + db: DB; + mailer: Mailer; + clock?: Clock; + logger?: Logger; + /** Constant minimum duration for POST /login and /login/resend. Default 800ms. */ + minResponseMs?: number; +} + +export function createApp(deps: AppDeps): Express { + const { config, db, mailer } = deps; + const clock = deps.clock ?? systemClock; + const logger = deps.logger ?? createLogger(); + + const users = new UserStore(db, clock); + const challenges = new ChallengeStore(db, clock, config.otpSecret); + const sessions = new SessionStore(db, clock); + const limiter = new RateLimiter(db, clock); + + const app = express(); + app.disable("x-powered-by"); + app.set("trust proxy", config.trustProxy); + + app.use((req, res, next) => { + res.set({ + "Content-Security-Policy": + "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + // Magic-link URLs carry a token; never leak them via Referer. + "Referrer-Policy": "no-referrer", + "Cache-Control": "no-store", + }); + const started = Date.now(); + res.on("finish", () => { + // req.path excludes the query string, so ?token=... is never logged. + logger.info("request", { method: req.method, path: req.path, status: res.statusCode, ms: Date.now() - started }); + }); + next(); + }); + + // Reject cross-site form posts when the browser tells us the origin. + const appOrigin = new URL(config.appUrl).origin; + app.use((req, res, next) => { + const origin = req.get("origin"); + if (req.method === "POST" && origin && origin !== "null" && origin !== appOrigin) { + res.status(403).type("html").send(errorPage("Request blocked", "This form was submitted from another site, so it was blocked. Go back to sign in and try again.")); + return; + } + next(); + }); + + app.use(express.urlencoded({ extended: false, limit: "10kb" })); + + app.get("/", (_req, res) => res.redirect(302, "/dashboard")); + app.get("/assets/app.js", (_req, res) => { + res.set("Cache-Control", "public, max-age=3600").type("application/javascript").send(CLIENT_JS); + }); + app.use( + loginRouter({ + appUrl: config.appUrl, + otpSecret: config.otpSecret, + autoSignup: config.autoSignup, + secureCookies: config.secureCookies, + minResponseMs: deps.minResponseMs ?? 800, + users, + challenges, + sessions, + limiter, + mailer, + logger, + }), + ); + app.use(dashboardRouter({ sessions, secureCookies: config.secureCookies })); + + app.use((_req, res) => { + res.status(404).type("html").send(errorPage("Page not found", "The page you were looking for doesn't exist.")); + }); + + const onError: ErrorRequestHandler = (err, _req, res, _next) => { + const status = typeof err?.status === "number" && err.status < 500 ? err.status : 500; + if (status >= 500) logger.error("unhandled error", { error: err }); + res.status(status).type("html").send(status >= 500 + ? errorPage("Something went wrong", "An unexpected error occurred. Please try again.") + : errorPage("Request not valid", "The request couldn't be processed. Please try again.")); + }; + app.use(onError); + + return app; +} diff --git a/PasswordlessLogin/manishgupta412/src/auth/challenges.ts b/PasswordlessLogin/manishgupta412/src/auth/challenges.ts new file mode 100644 index 000000000..16d81fefb --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/auth/challenges.ts @@ -0,0 +1,142 @@ +import type { Clock } from "../clock.js"; +import type { DB } from "../db.js"; +import { hmac, safeEqualHex } from "./crypto.js"; + +export const CHALLENGE_TTL_MS = 10 * 60_000; +export const MAX_ATTEMPTS = 5; + +interface ChallengeRow { + id: number; + email: string; + method: "code" | "link"; + code_hash: string | null; + token_hash: string | null; + expires_at: number; + attempts: number; + used_at: number | null; + invalidated_at: number | null; +} + +export type FailureReason = + | "not_found" // no open challenge for this email / unknown link + | "replaced" // a newer challenge was issued for this email + | "used" + | "expired" + | "locked" // too many wrong codes + | "incorrect"; // wrong code, attempts remain + +export type VerifyResult = + | { ok: true; email: string } + | { ok: false; reason: FailureReason; email?: string; attemptsLeft?: number }; + +export class ChallengeStore { + constructor( + private readonly db: DB, + private readonly clock: Clock, + private readonly secret: string, + ) {} + + private hashCode(email: string, code: string): string { + return hmac(this.secret, `login-code:${email}`, code); + } + + private hashToken(token: string): string { + return hmac(this.secret, "login-link", token); + } + + /** + * Stores a new challenge holding exactly one credential (a code or a link token) + * and invalidates any earlier open ones for the same email. + */ + create(email: string, secret: { code: string } | { token: string }): void { + const now = this.clock.now(); + const isCode = "code" in secret; + this.db.transaction(() => { + this.db + .prepare( + `UPDATE login_challenges SET invalidated_at = ? + WHERE email = ? AND used_at IS NULL AND invalidated_at IS NULL`, + ) + .run(now, email); + this.db + .prepare( + `INSERT INTO login_challenges (email, method, code_hash, token_hash, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .run( + email, + isCode ? "code" : "link", + isCode ? this.hashCode(email, secret.code) : null, + isCode ? null : this.hashToken(secret.token), + now, + now + CHALLENGE_TTL_MS, + ); + })(); + } + + verifyCode(email: string, code: string): VerifyResult { + const row = this.db + .prepare( + `SELECT * FROM login_challenges + WHERE email = ? AND invalidated_at IS NULL + ORDER BY id DESC LIMIT 1`, + ) + .get(email) as ChallengeRow | undefined; + // A link-only challenge has no code, so there is nothing to guess here. + if (!row || row.code_hash === null) return { ok: false, reason: "not_found", email }; + + const blocked = this.checkUsable(row); + if (blocked) return blocked; + + if (!safeEqualHex(this.hashCode(email, code), row.code_hash)) { + const updated = this.db + .prepare( + `UPDATE login_challenges SET attempts = attempts + 1 + WHERE id = ? AND attempts < ? RETURNING attempts`, + ) + .get(row.id, MAX_ATTEMPTS) as { attempts: number } | undefined; + const attempts = updated?.attempts ?? MAX_ATTEMPTS; + if (attempts >= MAX_ATTEMPTS) return { ok: false, reason: "locked", email }; + return { ok: false, reason: "incorrect", email, attemptsLeft: MAX_ATTEMPTS - attempts }; + } + + return this.consume(row); + } + + verifyToken(token: string): VerifyResult { + const tokenHash = this.hashToken(token); + const row = this.db + .prepare("SELECT * FROM login_challenges WHERE token_hash = ?") + .get(tokenHash) as ChallengeRow | undefined; + if (!row?.token_hash || !safeEqualHex(tokenHash, row.token_hash)) { + return { ok: false, reason: "not_found" }; + } + if (row.invalidated_at !== null) return { ok: false, reason: "replaced", email: row.email }; + + const blocked = this.checkUsable(row); + if (blocked) return blocked; + return this.consume(row); + } + + private checkUsable(row: ChallengeRow): VerifyResult | null { + const email = row.email; + if (row.used_at !== null) return { ok: false, reason: "used", email }; + if (row.attempts >= MAX_ATTEMPTS) return { ok: false, reason: "locked", email }; + if (row.expires_at <= this.clock.now()) return { ok: false, reason: "expired", email }; + return null; + } + + /** Single-use: only the request that flips used_at from NULL wins. */ + private consume(row: ChallengeRow): VerifyResult { + const now = this.clock.now(); + const result = this.db + .prepare( + `UPDATE login_challenges SET used_at = ? + WHERE id = ? AND used_at IS NULL AND invalidated_at IS NULL + AND attempts < ? AND expires_at > ?`, + ) + .run(now, row.id, MAX_ATTEMPTS, now); + if (result.changes !== 1) return { ok: false, reason: "used", email: row.email }; + return { ok: true, email: row.email }; + } +} diff --git a/PasswordlessLogin/manishgupta412/src/auth/crypto.ts b/PasswordlessLogin/manishgupta412/src/auth/crypto.ts new file mode 100644 index 000000000..5c51b87fa --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/auth/crypto.ts @@ -0,0 +1,28 @@ +import crypto from "node:crypto"; + +/** Uniformly random 6-digit code, zero-padded ("004219"). */ +export function generateCode(): string { + return crypto.randomInt(0, 1_000_000).toString().padStart(6, "0"); +} + +/** 256-bit random token, URL-safe. Used for magic links and session ids. */ +export function generateToken(): string { + return crypto.randomBytes(32).toString("base64url"); +} + +/** HMAC-SHA256 as hex. The purpose prefix keeps hashes for different uses unrelated. */ +export function hmac(secret: string, purpose: string, value: string): string { + return crypto.createHmac("sha256", secret).update(`${purpose}:${value}`).digest("hex"); +} + +export function sha256(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +/** Constant-time comparison of two hex digests. */ +export function safeEqualHex(a: string, b: string): boolean { + const ab = Buffer.from(a, "hex"); + const bb = Buffer.from(b, "hex"); + if (ab.length === 0 || ab.length !== bb.length) return false; + return crypto.timingSafeEqual(ab, bb); +} diff --git a/PasswordlessLogin/manishgupta412/src/auth/rateLimit.ts b/PasswordlessLogin/manishgupta412/src/auth/rateLimit.ts new file mode 100644 index 000000000..d6301efb4 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/auth/rateLimit.ts @@ -0,0 +1,87 @@ +import type { Clock } from "../clock.js"; +import type { DB } from "../db.js"; + +export interface WindowRule { + max: number; + windowMs: number; +} + +const MINUTE = 60_000; + +/** Tunable limits. Fixed windows, counted in SQLite so they survive restarts. */ +export const RATE_LIMITS = { + /** Code/link requests (POST /login and /login/resend) per email address. */ + requestPerEmail: { max: 5, windowMs: 15 * MINUTE }, + /** Code/link requests per client IP. */ + requestPerIp: { max: 20, windowMs: 15 * MINUTE }, + /** Code and magic-link verification attempts per client IP. */ + verifyPerIp: { max: 60, windowMs: 15 * MINUTE }, + /** Minimum gap between two emails to the same address. */ + resendCooldownMs: 60_000, +} as const satisfies Record; + +export interface HitResult { + allowed: boolean; + retryAfterMs: number; +} + +interface Row { + window_start: number; + count: number; +} + +export class RateLimiter { + constructor( + private readonly db: DB, + private readonly clock: Clock, + ) {} + + /** Counts one request against `key` and reports whether it is within the rule. */ + hit(key: string, rule: WindowRule): HitResult { + const now = this.clock.now(); + return this.db.transaction((): HitResult => { + const row = this.db + .prepare("SELECT window_start, count FROM rate_limits WHERE key = ?") + .get(key) as Row | undefined; + + if (!row || now - row.window_start >= rule.windowMs) { + this.db + .prepare( + `INSERT INTO rate_limits (key, window_start, count) VALUES (?, ?, 1) + ON CONFLICT (key) DO UPDATE SET window_start = excluded.window_start, count = 1`, + ) + .run(key, now); + return { allowed: true, retryAfterMs: 0 }; + } + + const count = row.count + 1; + this.db.prepare("UPDATE rate_limits SET count = ? WHERE key = ?").run(count, key); + return { + allowed: count <= rule.max, + retryAfterMs: count <= rule.max ? 0 : row.window_start + rule.windowMs - now, + }; + })(); + } + + /** Milliseconds left on a cooldown started with startCooldown, or 0. */ + cooldownRemaining(key: string, cooldownMs: number): number { + const row = this.db + .prepare("SELECT window_start FROM rate_limits WHERE key = ?") + .get(key) as Pick | undefined; + if (!row) return 0; + return Math.max(0, row.window_start + cooldownMs - this.clock.now()); + } + + startCooldown(key: string): void { + this.db + .prepare( + `INSERT INTO rate_limits (key, window_start, count) VALUES (?, ?, 1) + ON CONFLICT (key) DO UPDATE SET window_start = excluded.window_start`, + ) + .run(key, this.clock.now()); + } + + clearCooldown(key: string): void { + this.db.prepare("DELETE FROM rate_limits WHERE key = ?").run(key); + } +} diff --git a/PasswordlessLogin/manishgupta412/src/auth/requestLogin.ts b/PasswordlessLogin/manishgupta412/src/auth/requestLogin.ts new file mode 100644 index 000000000..d685452e7 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/auth/requestLogin.ts @@ -0,0 +1,124 @@ +import { z } from "zod"; +import type { LoginEmail, LoginMethod, Mailer } from "../email/mailer.js"; +import { maskEmail, type Logger } from "../logger.js"; +import { checkEmailPage, cooldownPage, loginPage, rateLimitedPage, sendFailedPage } from "../views/auth.js"; +import type { ChallengeStore } from "./challenges.js"; +import { generateCode, generateToken, hmac } from "./crypto.js"; +import { RATE_LIMITS, type RateLimiter } from "./rateLimit.js"; +import type { UserStore } from "./users.js"; + +export const EmailSchema = z.string().trim().toLowerCase().max(254).pipe(z.email()); + +/** Which credential to email. Anything missing or unexpected falls back to a code. */ +export const MethodSchema = z.enum(["code", "link"]).catch("code"); + +export interface HtmlResult { + status: number; + html: string; +} + +export interface RequestLoginDeps { + appUrl: string; + otpSecret: string; + autoSignup: boolean; + users: UserStore; + challenges: ChallengeStore; + limiter: RateLimiter; + mailer: Mailer; + logger: Logger; +} + +/** Rate-limit keys are HMACs so the table never holds raw emails or IPs. */ +export function ipKey(secret: string, scope: string, ip: string): string { + return `${scope}:${hmac(secret, "rl-ip", ip)}`; +} + +function emailKey(secret: string, scope: string, email: string): string { + return `${scope}:${hmac(secret, "rl-email", email)}`; +} + +const COOLDOWN_SECONDS = RATE_LIMITS.resendCooldownMs / 1000; + +/** Seconds until this email may request another code/link (0 = now). */ +export function resendWaitSeconds(deps: Pick, email: string): number { + const ms = deps.limiter.cooldownRemaining(emailKey(deps.otpSecret, "cooldown", email), RATE_LIMITS.resendCooldownMs); + return Math.ceil(ms / 1000); +} + +/** + * Shared by POST /login and POST /login/resend. + * Known and unknown emails go through identical limit/cooldown bookkeeping and + * get the identical "check your email" page; only known emails are mailed. + */ +export async function requestLogin( + deps: RequestLoginDeps, + input: { email: unknown; method: unknown; ip: string }, +): Promise { + const { otpSecret, limiter, logger } = deps; + const method: LoginMethod = MethodSchema.parse(input.method); + + const ipHit = limiter.hit(ipKey(otpSecret, "request-ip", input.ip), RATE_LIMITS.requestPerIp); + if (!ipHit.allowed) { + logger.warn("login request rate limited", { scope: "ip" }); + return { status: 429, html: rateLimitedPage(ipHit.retryAfterMs) }; + } + + const parsed = EmailSchema.safeParse(input.email); + if (!parsed.success) { + const raw = typeof input.email === "string" ? input.email.slice(0, 254) : ""; + return { status: 400, html: loginPage({ email: raw, method, error: "Enter a valid email address, like name@example.com." }) }; + } + const email = parsed.data; + + const cooldownKey = emailKey(otpSecret, "cooldown", email); + const waitMs = limiter.cooldownRemaining(cooldownKey, RATE_LIMITS.resendCooldownMs); + if (waitMs > 0) { + const seconds = Math.ceil(waitMs / 1000); + logger.info("login request blocked by resend cooldown", { email: maskEmail(email), method, waitSeconds: seconds }); + return { + status: 429, + html: cooldownPage({ email, method, waitSeconds: seconds }), + }; + } + + const emailHit = limiter.hit(emailKey(otpSecret, "request-email", email), RATE_LIMITS.requestPerEmail); + if (!emailHit.allowed) { + logger.warn("login request rate limited", { scope: "email", email: maskEmail(email) }); + return { status: 429, html: rateLimitedPage(emailHit.retryAfterMs) }; + } + + limiter.startCooldown(cooldownKey); + + const user = deps.users.findByEmail(email); + if (!user && !deps.autoSignup) { + logger.info("login requested", { email: maskEmail(email), method }); + return { status: 200, html: checkEmailPage({ email, method, waitSeconds: COOLDOWN_SECONDS }) }; + } + + // Generate and send only the credential the user asked for. + let secret: { code: string } | { token: string }; + let message: LoginEmail; + if (method === "code") { + const code = generateCode(); + secret = { code }; + message = { to: email, method, code }; + } else { + const token = generateToken(); + secret = { token }; + message = { to: email, method, link: `${deps.appUrl}/login/link?token=${token}` }; + } + + try { + await deps.mailer.sendLoginEmail(message); + } catch (err) { + // Let the user retry right away; the attempt still counts toward rate limits. + limiter.clearCooldown(cooldownKey); + logger.error("login email send failed", { email: maskEmail(email), error: err }); + return { status: 502, html: sendFailedPage(email, method) }; + } + + // Store only after the provider accepted the message. + deps.challenges.create(email, secret); + logger.info("login requested", { email: maskEmail(email), method }); + return { status: 200, html: checkEmailPage({ email, method, waitSeconds: COOLDOWN_SECONDS }) }; +} diff --git a/PasswordlessLogin/manishgupta412/src/auth/sessions.ts b/PasswordlessLogin/manishgupta412/src/auth/sessions.ts new file mode 100644 index 000000000..60e019ce5 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/auth/sessions.ts @@ -0,0 +1,100 @@ +import type { RequestHandler, Response } from "express"; +import type { Clock } from "../clock.js"; +import type { DB } from "../db.js"; +import { generateToken, sha256 } from "./crypto.js"; +import type { User } from "./users.js"; + +export const SESSION_COOKIE = "sid"; +export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +export class SessionStore { + constructor( + private readonly db: DB, + private readonly clock: Clock, + ) {} + + /** Returns the raw session id for the cookie; only its SHA-256 is stored. */ + create(userId: number): { id: string; expiresAt: number } { + const id = generateToken(); + const now = this.clock.now(); + const expiresAt = now + SESSION_TTL_MS; + this.db + .prepare("INSERT INTO sessions (id_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)") + .run(sha256(id), userId, now, expiresAt); + return { id, expiresAt }; + } + + findUser(id: string): User | undefined { + return this.db + .prepare( + `SELECT users.id, users.email FROM sessions + JOIN users ON users.id = sessions.user_id + WHERE sessions.id_hash = ? AND sessions.expires_at > ?`, + ) + .get(sha256(id), this.clock.now()) as User | undefined; + } + + destroy(id: string): void { + this.db.prepare("DELETE FROM sessions WHERE id_hash = ?").run(sha256(id)); + } +} + +/** Minimal Cookie header parser (no cookie-parser dependency). */ +export function parseCookies(header: string | undefined): Record { + const out: Record = {}; + if (!header) return out; + for (const part of header.split(";")) { + const eq = part.indexOf("="); + if (eq === -1) continue; + const name = part.slice(0, eq).trim(); + if (!name || name in out) continue; + let value = part.slice(eq + 1).trim(); + if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1); + try { + out[name] = decodeURIComponent(value); + } catch { + out[name] = value; + } + } + return out; +} + +export interface CookieOptions { + secure: boolean; +} + +export function setSessionCookie(res: Response, id: string, opts: CookieOptions): void { + const parts = [ + `${SESSION_COOKIE}=${id}`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + `Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`, + ]; + if (opts.secure) parts.push("Secure"); + res.append("Set-Cookie", parts.join("; ")); +} + +export function clearSessionCookie(res: Response, opts: CookieOptions): void { + const parts = [`${SESSION_COOKIE}=`, "Path=/", "HttpOnly", "SameSite=Lax", "Max-Age=0"]; + if (opts.secure) parts.push("Secure"); + res.append("Set-Cookie", parts.join("; ")); +} + +export function readSessionId(cookieHeader: string | undefined): string | undefined { + return parseCookies(cookieHeader)[SESSION_COOKIE] || undefined; +} + +/** Puts the signed-in user on res.locals.user, or redirects to /login. */ +export function requireSession(sessions: SessionStore): RequestHandler { + return (req, res, next) => { + const id = readSessionId(req.headers.cookie); + const user = id ? sessions.findUser(id) : undefined; + if (!user) { + res.redirect(302, "/login"); + return; + } + res.locals.user = user; + next(); + }; +} diff --git a/PasswordlessLogin/manishgupta412/src/auth/users.ts b/PasswordlessLogin/manishgupta412/src/auth/users.ts new file mode 100644 index 000000000..0a0b35224 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/auth/users.ts @@ -0,0 +1,28 @@ +import type { Clock } from "../clock.js"; +import type { DB } from "../db.js"; + +export interface User { + id: number; + email: string; +} + +export class UserStore { + constructor( + private readonly db: DB, + private readonly clock: Clock, + ) {} + + findByEmail(email: string): User | undefined { + return this.db.prepare("SELECT id, email FROM users WHERE email = ?").get(email) as + | User + | undefined; + } + + /** Inserts the user if missing and returns it (idempotent). */ + findOrCreate(email: string): User { + this.db + .prepare("INSERT INTO users (email, created_at) VALUES (?, ?) ON CONFLICT (email) DO NOTHING") + .run(email, this.clock.now()); + return this.findByEmail(email)!; + } +} diff --git a/PasswordlessLogin/manishgupta412/src/cli/addUser.ts b/PasswordlessLogin/manishgupta412/src/cli/addUser.ts new file mode 100644 index 000000000..f105722e5 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/cli/addUser.ts @@ -0,0 +1,19 @@ +// Usage: npm run user:add -- you@example.com +import dotenv from "dotenv"; +import { EmailSchema } from "../auth/requestLogin.js"; +import { UserStore } from "../auth/users.js"; +import { systemClock } from "../clock.js"; +import { openDatabase } from "../db.js"; + +dotenv.config({ quiet: true }); + +const parsed = EmailSchema.safeParse(process.argv[2]); +if (!parsed.success) { + console.error("Usage: npm run user:add -- you@example.com"); + process.exit(1); +} + +const db = openDatabase(process.env.DATABASE_PATH?.trim() || "./data/app.db"); +const user = new UserStore(db, systemClock).findOrCreate(parsed.data); +console.log(`User ready: ${user.email} (id ${user.id})`); +db.close(); diff --git a/PasswordlessLogin/manishgupta412/src/clock.ts b/PasswordlessLogin/manishgupta412/src/clock.ts new file mode 100644 index 000000000..7acf1f338 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/clock.ts @@ -0,0 +1,7 @@ +/** Injectable time source so tests can move time forward deterministically. */ +export interface Clock { + /** Milliseconds since the Unix epoch. */ + now(): number; +} + +export const systemClock: Clock = { now: () => Date.now() }; diff --git a/PasswordlessLogin/manishgupta412/src/config.ts b/PasswordlessLogin/manishgupta412/src/config.ts new file mode 100644 index 000000000..391bee15a --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/config.ts @@ -0,0 +1,78 @@ +import { z } from "zod"; + +const boolString = z + .enum(["true", "false"], { error: 'must be "true" or "false"' }) + .default("false") + .transform((v) => v === "true"); + +const EnvSchema = z.object({ + MAILTRAP_TOKEN: z.string().min(1), + MAIL_FROM_EMAIL: z.email(), + MAIL_FROM_NAME: z.string().min(1).default("Login"), + APP_URL: z.url({ protocol: /^https?$/, error: "must be an http(s) URL" }), + PORT: z.coerce.number().int().min(1).max(65535).default(3000), + OTP_SECRET: z.string().min(32, "must be at least 32 characters"), + AUTO_SIGNUP: boolString, + DATABASE_PATH: z.string().min(1).default("./data/app.db"), + TRUST_PROXY: boolString, +}); + +export interface Config { + mailtrapToken: string; + mailFromEmail: string; + mailFromName: string; + appUrl: string; + port: number; + otpSecret: string; + autoSignup: boolean; + databasePath: string; + trustProxy: boolean; + /** Derived: cookies get the Secure flag when the app is served over https. */ + secureCookies: boolean; +} + +export class ConfigError extends Error { + override name = "ConfigError"; +} + +/** + * Validates environment variables. Throws ConfigError listing every problem. + * Error messages name the variable and the rule only, never the value. + */ +export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { + // Treat empty strings as unset so `FOO=` in .env reports "missing". + const input = Object.fromEntries( + Object.keys(EnvSchema.shape).map((key) => { + const value = env[key]?.trim(); + return [key, value === "" ? undefined : value]; + }), + ); + + const result = EnvSchema.safeParse(input); + if (!result.success) { + const lines = result.error.issues.map((issue) => { + const key = String(issue.path[0] ?? "env"); + const reason = input[key] === undefined ? "is missing" : issue.message; + return ` - ${key}: ${reason}`; + }); + throw new ConfigError( + `Invalid environment configuration:\n${lines.join("\n")}\n` + + "Copy .env.example to .env and fill in the values.", + ); + } + + const e = result.data; + const appUrl = e.APP_URL.replace(/\/+$/, ""); + return { + mailtrapToken: e.MAILTRAP_TOKEN, + mailFromEmail: e.MAIL_FROM_EMAIL, + mailFromName: e.MAIL_FROM_NAME, + appUrl, + port: e.PORT, + otpSecret: e.OTP_SECRET, + autoSignup: e.AUTO_SIGNUP, + databasePath: e.DATABASE_PATH, + trustProxy: e.TRUST_PROXY, + secureCookies: appUrl.startsWith("https://"), + }; +} diff --git a/PasswordlessLogin/manishgupta412/src/db.ts b/PasswordlessLogin/manishgupta412/src/db.ts new file mode 100644 index 000000000..4f49e737c --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/db.ts @@ -0,0 +1,82 @@ +import fs from "node:fs"; +import path from "node:path"; +import Database from "better-sqlite3"; + +export type DB = Database.Database; + +/** Ordered migrations; index + 1 is the schema version stored in PRAGMA user_version. */ +const MIGRATIONS: string[] = [ + ` + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL + ); + + -- Each challenge holds exactly one credential: a code or a magic-link token. + -- Hashes are HMAC-SHA256(OTP_SECRET, ...). Plaintext is never stored. + CREATE TABLE login_challenges ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL, + method TEXT NOT NULL CHECK (method IN ('code', 'link')), + code_hash TEXT, + token_hash TEXT UNIQUE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + used_at INTEGER, + invalidated_at INTEGER, + CHECK ( + (method = 'code' AND code_hash IS NOT NULL AND token_hash IS NULL) OR + (method = 'link' AND token_hash IS NOT NULL AND code_hash IS NULL) + ) + ); + CREATE INDEX idx_login_challenges_email ON login_challenges (email, id); + + -- id_hash is SHA-256 of the cookie value. The raw session id only lives in the cookie. + CREATE TABLE sessions ( + id_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE INDEX idx_sessions_expires_at ON sessions (expires_at); + + -- Fixed-window counters and cooldown timestamps. Keys contain HMACs, not raw emails/IPs. + CREATE TABLE rate_limits ( + key TEXT PRIMARY KEY, + window_start INTEGER NOT NULL, + count INTEGER NOT NULL + ); + `, +]; + +export function openDatabase(file: string): DB { + if (file !== ":memory:") { + fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true }); + } + const db = new Database(file); + db.pragma("journal_mode = WAL"); + db.pragma("foreign_keys = ON"); + db.pragma("busy_timeout = 5000"); + migrate(db); + return db; +} + +function migrate(db: DB): void { + const current = db.pragma("user_version", { simple: true }) as number; + for (let v = current; v < MIGRATIONS.length; v++) { + db.transaction(() => { + db.exec(MIGRATIONS[v]!); + db.pragma(`user_version = ${v + 1}`); + })(); + } +} + +/** Removes rows that can no longer affect anything. Safe to run periodically. */ +export function pruneExpired(db: DB, now: number): void { + const dayAgo = now - 24 * 60 * 60 * 1000; + db.prepare("DELETE FROM login_challenges WHERE expires_at < ?").run(dayAgo); + db.prepare("DELETE FROM sessions WHERE expires_at < ?").run(now); + db.prepare("DELETE FROM rate_limits WHERE window_start < ?").run(dayAgo); +} diff --git a/PasswordlessLogin/manishgupta412/src/email/mailer.ts b/PasswordlessLogin/manishgupta412/src/email/mailer.ts new file mode 100644 index 000000000..1cdb59f6e --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/email/mailer.ts @@ -0,0 +1,12 @@ +export type LoginMethod = "code" | "link"; + +/** One email carries exactly one credential: the one the user asked for. */ +export type LoginEmail = + | { to: string; method: "code"; code: string } + | { to: string; method: "link"; link: string }; + +/** Email transport boundary. Production uses Mailtrap; tests inject a fake. */ +export interface Mailer { + /** Resolves when the provider accepted the message; rejects otherwise. */ + sendLoginEmail(email: LoginEmail): Promise; +} diff --git a/PasswordlessLogin/manishgupta412/src/email/mailtrap.ts b/PasswordlessLogin/manishgupta412/src/email/mailtrap.ts new file mode 100644 index 000000000..990a6fab3 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/email/mailtrap.ts @@ -0,0 +1,31 @@ +import { MailtrapClient } from "mailtrap"; +import type { Mailer } from "./mailer.js"; +import { renderLoginEmail } from "./templates.js"; + +export interface MailtrapMailerOptions { + token: string; + fromEmail: string; + fromName: string; +} + +/** + * Sends through the Mailtrap Email API Transactional stream + * (no `sandbox` / `bulk` flags => send.api.mailtrap.io). + */ +export function createMailtrapMailer(opts: MailtrapMailerOptions): Mailer { + const client = new MailtrapClient({ token: opts.token }); + + return { + async sendLoginEmail(email) { + const { subject, text, html } = renderLoginEmail(email); + await client.send({ + from: { email: opts.fromEmail, name: opts.fromName }, + to: [{ email: email.to }], + subject, + text, + html, + category: email.method === "code" ? "login-code" : "login-link", + }); + }, + }; +} diff --git a/PasswordlessLogin/manishgupta412/src/email/templates.ts b/PasswordlessLogin/manishgupta412/src/email/templates.ts new file mode 100644 index 000000000..016c28516 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/email/templates.ts @@ -0,0 +1,107 @@ +import { CHALLENGE_TTL_MS } from "../auth/challenges.js"; +import { escapeHtml } from "../views/layout.js"; +import type { LoginEmail } from "./mailer.js"; + +export interface RenderedEmail { + subject: string; + text: string; + html: string; +} + +const TTL_MINUTES = Math.round(CHALLENGE_TTL_MS / 60_000); +const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"; +const MONO = "ui-monospace,SFMono-Regular,Menlo,Consolas,monospace"; + +function footerText(to: string): string { + return `Sent to ${to} because someone asked to sign in. If that wasn't you, you can safely ignore this email.`; +} + +/** + * Email-safe layout: tables and inline styles only. The header gradient falls back + * to solid blue in clients without CSS gradients (e.g. Outlook). + */ +function htmlShell(opts: { title: string; preheader: string; heading: string; content: string; to: string }): string { + const to = escapeHtml(opts.to); + return ` + +${escapeHtml(opts.title)} + +
${escapeHtml(opts.preheader)}
+
+ + + +
+

Sign-in request

+

${escapeHtml(opts.heading)}

+
+ ${opts.content} + + +
Expires in ${TTL_MINUTES} minutes and works once.
+
+

Sent to ${to} because someone asked to sign in.
If that wasn't you, you can safely ignore this email.

+
+ +`; +} + +/** One bordered box per digit, so the code reads like the boxes on the sign-in page. */ +function codeBoxes(code: string): string { + const cells = [...code] + .map( + (d) => + `
${escapeHtml(d)}
`, + ) + .join(""); + return `${cells}
`; +} + +export function renderLoginEmail(email: LoginEmail): RenderedEmail { + if (email.method === "code") { + const subject = `${email.code} is your login code`; + // The code goes on the first line so it shows up in notification previews. + const text = [ + email.code, + "", + "Enter this code on the sign-in page.", + `It expires in ${TTL_MINUTES} minutes and works once.`, + "", + footerText(email.to), + ].join("\n"); + const c = escapeHtml(email.code); + const html = htmlShell({ + title: subject, + preheader: subject, + heading: "Your login code", + to: email.to, + content: `

Enter this code on the sign-in page:

+ ${codeBoxes(email.code)} +

You can also copy it: ${c}

`, + }); + return { subject, text, html }; + } + + const subject = "Your magic link"; + const text = [ + "Click the link below to sign in:", + email.link, + "", + `It expires in ${TTL_MINUTES} minutes and works once.`, + "", + footerText(email.to), + ].join("\n"); + const l = escapeHtml(email.link); + const html = htmlShell({ + title: subject, + preheader: "Click to sign in", + heading: "Sign in with one click", + to: email.to, + content: `

Click the button to finish signing in to your account.

+
+ Sign in +
+

Or paste this link into your browser:
${l}

`, + }); + return { subject, text, html }; +} diff --git a/PasswordlessLogin/manishgupta412/src/logger.ts b/PasswordlessLogin/manishgupta412/src/logger.ts new file mode 100644 index 000000000..e3a040641 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/logger.ts @@ -0,0 +1,67 @@ +type Level = "info" | "warn" | "error"; +type Fields = Record; + +export interface Logger { + info(msg: string, fields?: Fields): void; + warn(msg: string, fields?: Fields): void; + error(msg: string, fields?: Fields): void; +} + +/** Field names whose values are never written to logs. */ +const SENSITIVE_KEY = + /^(code|otp|token|link|url|magiclink|subject|text|html|body|secret|password|cookie|set-cookie|authorization|mailtraptoken|otpsecret)$/i; + +/** Defense in depth for free-form strings (e.g. error messages, URLs). */ +const SENSITIVE_PATTERNS: [RegExp, string][] = [ + [/([?&]token=)[^&\s"'<>]+/gi, "$1[REDACTED]"], + [/\b\d{6}\b/g, "[REDACTED]"], +]; + +const REDACTED = "[REDACTED]"; + +function scrubString(value: string): string { + return SENSITIVE_PATTERNS.reduce((s, [re, rep]) => s.replace(re, rep), value); +} + +export function redact(value: unknown, depth = 0): unknown { + if (depth > 6) return "[Truncated]"; + if (typeof value === "string") return scrubString(value); + if (value instanceof Error) { + return { name: value.name, message: scrubString(value.message) }; + } + if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1)); + if (value && typeof value === "object") { + const out: Fields = {}; + for (const [key, v] of Object.entries(value)) { + out[key] = SENSITIVE_KEY.test(key) ? REDACTED : redact(v, depth + 1); + } + return out; + } + return value; +} + +/** "alice@example.com" -> "a***@example.com" so logs don't collect full addresses. */ +export function maskEmail(email: string): string { + const at = email.lastIndexOf("@"); + if (at < 1) return "***"; + return `${email[0]}***${email.slice(at)}`; +} + +export function createLogger( + sink: (line: string) => void = (line) => process.stdout.write(line + "\n"), +): Logger { + const write = (level: Level, msg: string, fields?: Fields) => { + const entry = { + time: new Date().toISOString(), + level, + msg: scrubString(msg), + ...(fields ? (redact(fields) as Fields) : {}), + }; + sink(JSON.stringify(entry)); + }; + return { + info: (msg, fields) => write("info", msg, fields), + warn: (msg, fields) => write("warn", msg, fields), + error: (msg, fields) => write("error", msg, fields), + }; +} diff --git a/PasswordlessLogin/manishgupta412/src/routes/dashboard.ts b/PasswordlessLogin/manishgupta412/src/routes/dashboard.ts new file mode 100644 index 000000000..d9fab14b0 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/routes/dashboard.ts @@ -0,0 +1,27 @@ +import { Router } from "express"; +import { + clearSessionCookie, + readSessionId, + requireSession, + type SessionStore, +} from "../auth/sessions.js"; +import type { User } from "../auth/users.js"; +import { dashboardPage } from "../views/dashboard.js"; + +export function dashboardRouter(deps: { sessions: SessionStore; secureCookies: boolean }): Router { + const router = Router(); + + router.get("/dashboard", requireSession(deps.sessions), (_req, res) => { + const user = res.locals.user as User; + res.type("html").send(dashboardPage(user.email)); + }); + + router.post("/logout", (req, res) => { + const id = readSessionId(req.headers.cookie); + if (id) deps.sessions.destroy(id); + clearSessionCookie(res, { secure: deps.secureCookies }); + res.redirect(303, "/login"); + }); + + return router; +} diff --git a/PasswordlessLogin/manishgupta412/src/routes/login.ts b/PasswordlessLogin/manishgupta412/src/routes/login.ts new file mode 100644 index 000000000..f9af14125 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/routes/login.ts @@ -0,0 +1,184 @@ +import { performance } from "node:perf_hooks"; +import { setTimeout as sleep } from "node:timers/promises"; +import { Router, type Request, type RequestHandler, type Response } from "express"; +import { z } from "zod"; +import type { VerifyResult } from "../auth/challenges.js"; +import { RATE_LIMITS } from "../auth/rateLimit.js"; +import { EmailSchema, ipKey, requestLogin, resendWaitSeconds, type HtmlResult, type RequestLoginDeps } from "../auth/requestLogin.js"; +import { readSessionId, setSessionCookie, type SessionStore } from "../auth/sessions.js"; +import { maskEmail, type Logger } from "../logger.js"; +import { + checkEmailPage, + errorPage, + loginPage, + magicLinkPage, + rateLimitedPage, + verifyFailedPage, +} from "../views/auth.js"; + +export interface LoginRouterDeps extends RequestLoginDeps { + sessions: SessionStore; + secureCookies: boolean; + /** Every POST /login and /login/resend takes at least this long (anti-enumeration). */ + minResponseMs: number; +} + +const CodeSchema = z + .string() + .transform((s) => s.replace(/\s+/g, "")) + .pipe(z.string().regex(/^\d{6}$/)); + +const TokenSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/); + +const MESSAGES = { + code: { + not_found: { title: "No active code", text: "There's no active code for this email. Request a new one to continue." }, + replaced: { title: "Code replaced", text: "A newer code was sent. Use the code from the most recent email." }, + used: { title: "Code already used", text: "This code has already been used. Each code works only once." }, + expired: { title: "Code expired", text: "This code has expired. Codes are valid for 10 minutes." }, + locked: { + title: "Code locked", + text: "Too many incorrect attempts. For your security, this code no longer works. Request a new one to continue.", + }, + }, + link: { + not_found: { + title: "Link not valid", + text: "This magic link isn't valid. Make sure you opened the whole link from the email, or request a new one.", + }, + replaced: { title: "Link replaced", text: "This link was replaced by a newer one. Use the link in the most recent email." }, + used: { title: "Link already used", text: "This magic link has already been used. Each link works only once." }, + expired: { title: "Link expired", text: "This magic link has expired. Links are valid for 10 minutes." }, + locked: { title: "Link locked", text: "Too many incorrect attempts. For your security, this sign-in request was locked." }, + }, +} as const; + +function sendHtml(res: Response, result: HtmlResult): void { + res.status(result.status).type("html").send(result.html); +} + +export function loginRouter(deps: LoginRouterDeps): Router { + const router = Router(); + const { limiter, logger } = deps; + + /** Pads the response so known/unknown/rate-limited paths take the same minimum time. */ + const padded = (fn: (req: Request) => Promise): RequestHandler => + async (req, res) => { + const started = performance.now(); + let result: HtmlResult; + try { + result = await fn(req); + } catch (err) { + logger.error("login request failed", { error: err }); + result = { status: 500, html: errorPage("Something went wrong", "An unexpected error occurred. Please try again.") }; + } + const remaining = deps.minResponseMs - (performance.now() - started); + if (remaining > 0) await sleep(remaining); + sendHtml(res, result); + }; + + const handleRequest = padded((req) => + requestLogin(deps, { email: req.body?.email, method: req.body?.method, ip: req.ip ?? "unknown" }), + ); + + router.get("/login", (_req, res) => sendHtml(res, { status: 200, html: loginPage() })); + router.post("/login", handleRequest); + router.post("/login/resend", handleRequest); + + const verifyLimited = (req: Request, res: Response): boolean => { + const hit = limiter.hit(ipKey(deps.otpSecret, "verify-ip", req.ip ?? "unknown"), RATE_LIMITS.verifyPerIp); + if (hit.allowed) return false; + logger.warn("verify rate limited", { scope: "ip" }); + sendHtml(res, { status: 429, html: rateLimitedPage(hit.retryAfterMs) }); + return true; + }; + + const signIn = (req: Request, res: Response, email: string, method: string): void => { + const user = deps.autoSignup ? deps.users.findOrCreate(email) : deps.users.findByEmail(email); + if (!user) { + sendHtml(res, { status: 400, html: verifyFailedPage({ title: MESSAGES.code.not_found.title, message: MESSAGES.code.not_found.text, method: "code", email }) }); + return; + } + // Rotate: drop any session the browser already had (prevents session fixation). + const previous = readSessionId(req.headers.cookie); + if (previous) deps.sessions.destroy(previous); + + const session = deps.sessions.create(user.id); + setSessionCookie(res, session.id, { secure: deps.secureCookies }); + logger.info("signed in", { email: maskEmail(email), method }); + res.redirect(303, "/dashboard"); + }; + + const failure = (res: Response, result: Exclude, kind: "code" | "link") => { + if (result.reason === "incorrect") { + const left = result.attemptsLeft ?? 0; + sendHtml(res, { + status: 401, + html: checkEmailPage({ + email: result.email ?? "", + method: "code", + waitSeconds: resendWaitSeconds(deps, result.email ?? ""), + error: `That code isn't correct. You have ${left} attempt${left === 1 ? "" : "s"} left.`, + }), + }); + return; + } + sendHtml(res, { + status: 400, + html: verifyFailedPage({ title: MESSAGES[kind][result.reason].title, message: MESSAGES[kind][result.reason].text, method: kind, email: result.email }), + }); + }; + + router.post("/login/verify", (req, res) => { + if (verifyLimited(req, res)) return; + + const email = EmailSchema.safeParse(req.body?.email); + if (!email.success) { + sendHtml(res, { status: 400, html: loginPage({ error: "Enter a valid email address, like name@example.com." }) }); + return; + } + const code = CodeSchema.safeParse(req.body?.code); + if (!code.success) { + sendHtml(res, { + status: 400, + html: checkEmailPage({ email: email.data, method: "code", waitSeconds: resendWaitSeconds(deps, email.data), error: "Enter the 6-digit code from the email." }), + }); + return; + } + + const result = deps.challenges.verifyCode(email.data, code.data); + if (!result.ok) { + logger.info("code verification failed", { email: maskEmail(email.data), reason: result.reason }); + failure(res, result, "code"); + return; + } + signIn(req, res, result.email, "code"); + }); + + router.get("/login/link", (req, res) => { + const token = TokenSchema.safeParse(req.query.token); + if (!token.success) { + sendHtml(res, { status: 400, html: verifyFailedPage({ title: MESSAGES.link.not_found.title, message: MESSAGES.link.not_found.text, method: "link" }) }); + return; + } + sendHtml(res, { status: 200, html: magicLinkPage(token.data) }); + }); + + router.post("/login/link", (req, res) => { + if (verifyLimited(req, res)) return; + + const token = TokenSchema.safeParse(req.body?.token); + const result = token.success + ? deps.challenges.verifyToken(token.data) + : ({ ok: false, reason: "not_found" } as const); + if (!result.ok) { + logger.info("link verification failed", { reason: result.reason }); + failure(res, result, "link"); + return; + } + signIn(req, res, result.email, "link"); + }); + + return router; +} + diff --git a/PasswordlessLogin/manishgupta412/src/server.ts b/PasswordlessLogin/manishgupta412/src/server.ts new file mode 100644 index 000000000..008c29a40 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/server.ts @@ -0,0 +1,46 @@ +import dotenv from "dotenv"; +import { createApp } from "./app.js"; +import { systemClock } from "./clock.js"; +import { ConfigError, loadConfig, type Config } from "./config.js"; +import { openDatabase, pruneExpired } from "./db.js"; +import { createMailtrapMailer } from "./email/mailtrap.js"; +import { createLogger } from "./logger.js"; + +dotenv.config({ quiet: true }); + +let config: Config; +try { + config = loadConfig(process.env); +} catch (err) { + if (err instanceof ConfigError) { + console.error(err.message); + process.exit(1); + } + throw err; +} + +const logger = createLogger(); +const db = openDatabase(config.databasePath); +const mailer = createMailtrapMailer({ + token: config.mailtrapToken, + fromEmail: config.mailFromEmail, + fromName: config.mailFromName, +}); + +const app = createApp({ config, db, mailer, clock: systemClock, logger }); + +pruneExpired(db, systemClock.now()); +setInterval(() => pruneExpired(db, systemClock.now()), 60 * 60 * 1000).unref(); + +const server = app.listen(config.port, () => { + logger.info("server started", { appUrl: config.appUrl, port: config.port, autoSignup: config.autoSignup }); +}); + +const shutdown = () => { + server.close(() => { + db.close(); + process.exit(0); + }); +}; +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/PasswordlessLogin/manishgupta412/src/views/auth.ts b/PasswordlessLogin/manishgupta412/src/views/auth.ts new file mode 100644 index 000000000..9d493ca8e --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/views/auth.ts @@ -0,0 +1,207 @@ +import type { LoginMethod } from "../email/mailer.js"; +import { alert, escapeHtml, icon, layout } from "./layout.js"; + +const METHOD_NOUN: Record = { code: "code", link: "magic link" }; +const SEND_LABEL: Record = { code: "Send code", link: "Send link" }; +const RESEND_LABEL: Record = { code: "Resend code", link: "Resend link" }; +const NEW_LABEL: Record = { code: "Send a new code", link: "Send a new link" }; +const SWITCH_LABEL: Record = { + code: "Email me a code instead", + link: "Email me a magic link instead", +}; +const OTHER: Record = { code: "link", link: "code" }; + +function submitButton(label: string, loadingLabel: string, cls = "btn-primary", extra = ""): string { + return ``; +} + +/** Hidden-field form that asks for another email; `waitSeconds` > 0 shows a live timer. */ +export function resendForm( + email: string, + method: LoginMethod, + label: string, + cls = "btn-secondary", + waitSeconds = 0, +): string { + const wait = waitSeconds > 0 ? ` data-wait="${Math.ceil(waitSeconds)}"` : ""; + return `
+ + + +
`; +} + +function codeForm(email: string, opts: { error?: boolean; autofocus?: boolean }): string { + const err = opts.error ? " has-error" : ""; + return `
+ + + +

The code expires 10 minutes after it was sent.

+ ${submitButton("Sign in", "Signing in…")} +
`; +} + +const changeEmail = ``; + +export function loginPage(opts: { email?: string; method?: LoginMethod; error?: string } = {}): string { + const method = opts.method ?? "code"; + const choice = (value: LoginMethod, title: string, desc: string) => ` + `; + + return layout( + "Sign in", + `

Welcome back

+

Enter your email and we'll send you a way in.

+${alert("error", opts.error)} +
+
+ + +
+
+ Sign in with +
+ ${choice("code", "A 6-digit code", "Type it on the next screen")} + ${choice("link", "A magic link", "Click it in your inbox")} +
+
+ ${submitButton("Continue", "Sending…", "btn-primary", ` data-label-code="${SEND_LABEL.code}" data-label-link="${SEND_LABEL.link}"`)} +
`, + ); +} + +/** + * After a request. Identical for known and unknown addresses, so it never reveals + * whether an account exists. + */ +export function checkEmailPage(opts: { + email: string; + method: LoginMethod; + /** Seconds left on the resend cooldown; resend buttons show a live timer until then. */ + waitSeconds: number; + error?: string; + notice?: string; +}): string { + const email = escapeHtml(opts.email); + const { method } = opts; + const main = + method === "code" + ? `

If an account exists for ${email}, we've sent it a 6-digit code. Enter it below to sign in.

+${alert("notice", opts.notice)} +${alert("error", opts.error)} +${codeForm(opts.email, { error: Boolean(opts.error), autofocus: true })}` + : `

If an account exists for ${email}, we've sent it a magic link. Open the email and click Sign in.

+${alert("notice", opts.notice)} +${alert("error", opts.error)}`; + + return layout( + "Check your email", + `${icon("mail")} +

Check your email

+${main} +
+

Didn't get it? Check your spam folder, or request another.

+
+${resendForm(opts.email, method, RESEND_LABEL[method], "btn-secondary", opts.waitSeconds)} +${resendForm(opts.email, OTHER[method], SWITCH_LABEL[OTHER[method]], "btn-quiet", opts.waitSeconds)} +
+${changeEmail}`, + ); +} + +/** + * A request that hit the resend cooldown: nothing was sent this time. + * Identical for known and unknown addresses (the cooldown is tracked for both). + */ +export function cooldownPage(opts: { email: string; method: LoginMethod; waitSeconds: number }): string { + const email = escapeHtml(opts.email); + const { method } = opts; + const haveCode = + method === "code" + ? `

Already have a code from that email? Enter it here.

+${codeForm(opts.email, {})} +
` + : ""; + return layout( + "One moment", + `${icon("clock")} +

One moment

+

We emailed ${email} less than a minute ago.

+${alert("notice", `You can request a new ${METHOD_NOUN[method]} when the timer runs out.`)} +${haveCode} +${resendForm(opts.email, method, method === "code" ? "Send me a code" : "Send me a magic link", method === "code" ? "btn-secondary" : "btn-primary", opts.waitSeconds)} +${changeEmail}`, + ); +} + +/** Terminal verification failures (expired, used, locked…): say what happened and offer a fresh one. */ +export function verifyFailedPage(opts: { + title: string; + message: string; + method: LoginMethod; + email?: string; +}): string { + const action = opts.email + ? resendForm(opts.email, opts.method, NEW_LABEL[opts.method], "btn-primary") + : `Back to sign in`; + return layout( + opts.title, + `${icon("alert", "danger")} +

${escapeHtml(opts.title)}

+

${escapeHtml(opts.message)}

+${action} +${opts.email ? changeEmail : ""}`, + ); +} + +export function sendFailedPage(email: string, method: LoginMethod): string { + return layout( + "Email not sent", + `${icon("alert", "danger")} +

Email not sent

+

${escapeHtml("We couldn't send the email, try again. If it keeps failing, wait a few minutes and retry.")}

+${resendForm(email, method, "Try again", "btn-primary")} +${changeEmail}`, + ); +} + +export function rateLimitedPage(retryAfterMs: number): string { + const minutes = Math.max(1, Math.ceil(retryAfterMs / 60_000)); + return layout( + "Too many attempts", + `${icon("clock")} +

Too many attempts

+

Too many sign-in attempts. To protect your account, please try again in ${minutes} minute${minutes === 1 ? "" : "s"}.

+Back to sign in`, + ); +} + +/** Magic link landing page. GET never consumes the token (email scanners prefetch links). */ +export function magicLinkPage(token: string): string { + return layout( + "Finish signing in", + `${icon("lock")} +

Finish signing in

+

Click the button below to sign in on this device.

+
+ + ${submitButton("Continue signing in", "Signing in…")} +
`, + ); +} + +export function errorPage(title: string, message: string): string { + return layout( + title, + `${icon("alert", "danger")} +

${escapeHtml(title)}

+

${escapeHtml(message)}

+Back to sign in`, + ); +} diff --git a/PasswordlessLogin/manishgupta412/src/views/client.ts b/PasswordlessLogin/manishgupta412/src/views/client.ts new file mode 100644 index 000000000..069355376 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/views/client.ts @@ -0,0 +1,163 @@ +/** + * Progressive enhancement, served at /assets/app.js (same origin, so the CSP can + * stay `script-src 'self'`). Every page still works without it; the server + * enforces all limits regardless of what this script does. + */ +export const CLIENT_JS = `(() => { + "use strict"; + const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); + const setLabel = (button, text) => { + const label = button.querySelector(".label"); + if (label) label.textContent = text; + }; + + // Sign-in method choice: the button says what will happen. + for (const form of $$("form[data-method-form]")) { + const button = form.querySelector("button[type=submit]"); + const update = () => { + const picked = form.querySelector("input[name=method]:checked"); + const text = picked && { code: button.dataset.labelCode, link: button.dataset.labelLink }[picked.value]; + if (text) setLabel(button, text); + }; + form.addEventListener("change", update); + update(); + } + + // Six-box code entry. The original input becomes a hidden field the form submits. + for (const input of $$("input[data-otp]")) { + const form = input.form; + const length = 6; + const group = document.createElement("div"); + group.className = "otp"; + group.setAttribute("role", "group"); + group.setAttribute("aria-label", "6-digit code"); + const boxes = []; + for (let i = 0; i < length; i++) { + const box = document.createElement("input"); + box.type = "text"; + box.inputMode = "numeric"; + box.autocomplete = i === 0 ? "one-time-code" : "off"; + box.setAttribute("aria-label", "Digit " + (i + 1) + " of " + length); + boxes.push(box); + group.appendChild(box); + } + const hadError = input.classList.contains("has-error"); + if (hadError) group.classList.add("has-error", "shake"); + const autofocus = input.hasAttribute("autofocus"); + input.removeAttribute("id"); + input.removeAttribute("required"); + input.removeAttribute("pattern"); + input.removeAttribute("autofocus"); + input.type = "hidden"; + boxes[0].id = "code"; + input.insertAdjacentElement("afterend", group); + + const sync = () => { + input.value = boxes.map((b) => b.value).join(""); + group.classList.remove("has-error", "shake"); + if (/^\\d{6}$/.test(input.value)) form.requestSubmit(); + }; + const fill = (start, digits) => { + let i = start; + for (const d of digits) { + if (i >= length) break; + boxes[i++].value = d; + } + boxes[Math.min(i, length - 1)].focus(); + sync(); + }; + boxes.forEach((box, i) => { + box.addEventListener("input", () => { + const digits = box.value.replace(/\\D/g, ""); + if (digits.length > 1) { + box.value = ""; + fill(i, digits); + return; + } + box.value = digits; + if (digits && i < length - 1) boxes[i + 1].focus(); + sync(); + }); + box.addEventListener("keydown", (e) => { + if (e.key === "Backspace" && !box.value && i > 0) { + e.preventDefault(); + boxes[i - 1].value = ""; + boxes[i - 1].focus(); + sync(); + } else if (e.key === "ArrowLeft" && i > 0) { + e.preventDefault(); + boxes[i - 1].focus(); + } else if (e.key === "ArrowRight" && i < length - 1) { + e.preventDefault(); + boxes[i + 1].focus(); + } + }); + box.addEventListener("paste", (e) => { + const digits = (e.clipboardData ? e.clipboardData.getData("text") : "").replace(/\\D/g, ""); + if (!digits) return; + e.preventDefault(); + boxes.forEach((b) => (b.value = "")); + fill(0, digits); + }); + box.addEventListener("focus", () => box.select()); + }); + form.addEventListener("submit", (e) => { + if (/^\\d{6}$/.test(input.value)) return; + e.preventDefault(); + group.classList.remove("shake"); + void group.offsetWidth; + group.classList.add("has-error", "shake"); + (boxes.find((b) => !b.value) || boxes[0]).focus(); + }); + if (autofocus || hadError) boxes[0].focus(); + } + + // Loading state, and no double submits. + document.addEventListener("submit", (e) => { + const form = e.target; + if (e.defaultPrevented) return; + if (form.dataset.busy) { + e.preventDefault(); + return; + } + form.dataset.busy = "1"; + const button = e.submitter || form.querySelector("button[type=submit]"); + if (button && button.dataset.loadingLabel) { + button.classList.add("is-loading"); + button.setAttribute("aria-busy", "true"); + setLabel(button, button.dataset.loadingLabel); + } + }); + window.addEventListener("pageshow", (e) => { + if (!e.persisted) return; + for (const form of $$("form[data-busy]")) delete form.dataset.busy; + for (const b of $$(".is-loading")) b.classList.remove("is-loading"); + }); + + // Resend cooldown: disabled with a live timer until the server will accept it. + const fmt = (s) => Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0"); + for (const button of $$("button[data-wait]")) { + const wait = Number(button.dataset.wait); + if (!(wait > 0)) continue; + const label = button.querySelector(".label"); + const timer = button.querySelector(".timer"); + const deadline = Date.now() + wait * 1000; + button.disabled = true; + let id = 0; + const tick = () => { + const left = Math.ceil((deadline - Date.now()) / 1000); + if (left <= 0) { + button.disabled = false; + timer.textContent = ""; + button.removeAttribute("aria-label"); + clearInterval(id); + return; + } + timer.textContent = fmt(left); + button.setAttribute("aria-label", label.textContent + ", available in " + left + " seconds"); + }; + id = setInterval(tick, 250); + tick(); + } +})(); +`; diff --git a/PasswordlessLogin/manishgupta412/src/views/dashboard.ts b/PasswordlessLogin/manishgupta412/src/views/dashboard.ts new file mode 100644 index 000000000..5e4f020d6 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/views/dashboard.ts @@ -0,0 +1,13 @@ +import { escapeHtml, icon, layout } from "./layout.js"; + +export function dashboardPage(email: string): string { + return layout( + "You're signed in", + `${icon("check", "success")} +

You're signed in

+

Signed in as ${escapeHtml(email)}.

+
+ +
`, + ); +} diff --git a/PasswordlessLogin/manishgupta412/src/views/layout.ts b/PasswordlessLogin/manishgupta412/src/views/layout.ts new file mode 100644 index 000000000..056938d81 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/src/views/layout.ts @@ -0,0 +1,212 @@ +const ESCAPES: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +/** Escape every user-controlled value before putting it in HTML. */ +export function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (ch) => ESCAPES[ch]!); +} + +const STYLES = ` +:root{ + color-scheme:light; + --bg:#f2f4f7;--surface:#fff;--hover:#f6f7f9; + --ink:#1a2230;--muted:#5d6878;--placeholder:#98a2b3; + --line:#e1e5eb;--line-strong:#c5ccd6; + --primary:#2457c5;--primary-hover:#1c47a6;--primary-ink:#fff;--primary-soft:#edf2fc; + --danger:#b42318;--danger-ink:#912018;--danger-soft:#fef3f2;--danger-line:#f5c6c0; + --notice-ink:#1f4c9e;--notice-soft:#eff4fc;--notice-line:#cbdaf3; + --success:#1d7445;--success-soft:#ebf6ef; + --ring:0 0 0 3px rgba(36,87,197,.28); + --shadow:0 1px 2px rgba(16,24,40,.04),0 12px 32px -16px rgba(16,24,40,.16); +} +*,*::before,*::after{box-sizing:border-box} +html{-webkit-text-size-adjust:100%} +body{margin:0;background:var(--surface);color:var(--ink); + font:400 16px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; + -webkit-font-smoothing:antialiased} + +/* Split layout: illustrated panel on the left, the page on the right */ +.split{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);min-height:100vh;min-height:100dvh} +.panel{position:relative;overflow:hidden;display:flex;flex-direction:column;gap:32px;padding:56px clamp(32px,5vw,64px);color:#fff; + background:radial-gradient(42rem 30rem at 15% 0%,rgba(125,160,255,.45),transparent 60%), + radial-gradient(36rem 28rem at 100% 100%,rgba(139,92,246,.55),transparent 60%), + linear-gradient(155deg,#1d3fa3 0%,#2457c5 45%,#4338ca 100%)} +.panel h2{font-size:clamp(1.6rem,2.4vw,1.9rem);line-height:1.15;font-weight:700;letter-spacing:-.02em;margin:0 0 8px} +.panel-copy p{margin:0;color:#dbe4ff;font-size:.9375rem} +.flow{flex:1;display:flex;flex-direction:column;justify-content:center;gap:clamp(14px,2.4vh,28px);min-height:0} +.flow-card{position:relative;width:100%;max-width:330px;padding:16px 18px;background:#fff;color:var(--ink);border-radius:18px; + box-shadow:0 30px 60px -20px rgba(10,20,60,.55),0 2px 6px rgba(10,20,60,.15)} +.flow-card:nth-child(1){transform:rotate(-3deg)} +.flow-card:nth-child(2){margin-left:clamp(40px,22%,160px);transform:rotate(2deg)} +.flow-card:nth-child(3){margin-left:clamp(12px,6%,48px);max-width:300px;transform:rotate(-1.5deg)} +.flow-step{position:absolute;right:-10px;top:-10px;display:grid;place-items:center;width:26px;height:26px;border-radius:50%; + background:var(--ink);color:#fff;font-size:.8125rem;font-weight:700} +.flow-head{display:flex;align-items:center;gap:12px} +.flow-head b{display:block;font-size:.875rem;line-height:1.3} +.flow-head small{display:block;color:var(--muted);font-size:.75rem} +.flow-icon{display:grid;place-items:center;flex:none;width:34px;height:34px;border-radius:9px;background:linear-gradient(135deg,#3b6fe0,#4338ca)} +.flow-icon svg{width:18px;height:18px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round} +.flow-icon.ok{border-radius:50%;background:var(--success-soft)} +.flow-icon.ok svg{stroke:var(--success);stroke-width:2.6} +.flow-code{margin-top:12px;font:800 1.75rem/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.35em} +.flow-btn{display:inline-grid;place-items:center;margin-top:12px;height:32px;padding:0 22px;border-radius:8px;background:var(--primary);color:#fff;font-size:.8125rem;font-weight:600} +.panel-badges{display:flex;flex-wrap:wrap;gap:10px} +.panel-badges span{padding:6px 12px;font-size:.8125rem;color:#e6ecff;background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.22);border-radius:999px} +.pane{display:flex;align-items:center;justify-content:center;padding:48px 32px} +.pane-inner{width:100%;max-width:400px;min-width:0} +@media (max-width:860px){ + .split{grid-template-columns:1fr;min-height:0} + .panel{padding:28px 20px;gap:0} + .panel h2{font-size:1.5rem;margin:0} + .panel-copy p,.flow,.panel-badges{display:none} + .pane{align-items:flex-start;padding:32px 20px 48px} +} + +.badge{display:grid;place-items:center;width:44px;height:44px;border-radius:12px;margin-bottom:20px; + background:var(--primary-soft);color:var(--primary)} +.badge svg{width:22px;height:22px} +.badge.danger{background:var(--danger-soft);color:var(--danger)} +.badge.success{background:var(--success-soft);color:var(--success)} + +h1{font-size:1.85rem;line-height:1.2;font-weight:700;letter-spacing:-.02em;margin:0 0 8px} +p{margin:0 0 16px} +.lead{color:var(--muted);margin-bottom:24px} +.lead strong{color:var(--ink);font-weight:600;overflow-wrap:anywhere} +.small{font-size:.875rem;color:var(--muted)} +.center{text-align:center} +a{color:var(--primary);text-underline-offset:2px} +a:hover{color:var(--primary-hover)} +a:focus-visible{outline:none;box-shadow:var(--ring);border-radius:4px} +.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} + +label,legend{display:block;font-size:.875rem;font-weight:600;margin-bottom:6px;padding:0} +.field{margin-bottom:20px} +input[type=email],input[type=text]{width:100%;height:50px;padding:0 14px;font:inherit;color:var(--ink); + background:var(--surface);border:1.5px solid var(--line-strong);border-radius:10px; + transition:border-color .15s,box-shadow .15s} +input::placeholder{color:var(--placeholder)} +input:focus{outline:none;border-color:var(--primary);box-shadow:var(--ring)} +fieldset{border:0;padding:0;margin:0 0 24px;min-width:0} + +.choices{display:grid;gap:10px} +.choice{position:relative;display:block;margin:0;padding:14px 16px 14px 50px;border:1.5px solid var(--line); + border-radius:12px;cursor:pointer;font-weight:400;transition:border-color .15s,background-color .15s} +.choice:hover{border-color:var(--primary)} +.choice input{position:absolute;opacity:0;pointer-events:none} +.choice-title{display:block;font-weight:600;font-size:.9375rem} +.choice-desc{display:block;font-size:.8125rem;color:var(--muted);margin-top:2px} +.choice .dot{position:absolute;top:50%;left:17px;margin-top:-9px;width:18px;height:18px;border-radius:50%; + border:1.5px solid var(--line-strong);background:var(--surface);transition:all .15s} +.choice:has(input:checked){border-color:var(--primary);background:var(--primary-soft)} +.choice:has(input:checked) .dot{border-color:var(--primary);background:var(--primary);box-shadow:inset 0 0 0 4px var(--surface)} +.choice:has(input:focus-visible){box-shadow:inset 0 0 0 1px var(--primary),var(--ring)} + +.btn{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;min-height:50px;padding:10px 16px; + font:inherit;font-size:.9375rem;font-weight:600;line-height:1.3;text-align:center;text-decoration:none; + border:1px solid transparent;border-radius:10px;cursor:pointer; + transition:background-color .15s,border-color .15s,color .15s,opacity .15s} +.btn:focus-visible{outline:none;box-shadow:var(--ring)} +.btn-primary{background:var(--primary);color:var(--primary-ink)} +.btn-primary:hover{background:var(--primary-hover);color:var(--primary-ink)} +.btn-secondary{background:var(--surface);color:var(--ink);border-color:var(--line-strong)} +.btn-secondary:hover{background:var(--hover);color:var(--ink)} +.btn-quiet{background:none;color:var(--primary);min-height:40px;font-weight:500} +.btn-quiet:hover{text-decoration:underline;color:var(--primary-hover)} +.btn:disabled{cursor:not-allowed;opacity:.55} +.btn-quiet:disabled{color:var(--muted);opacity:1;text-decoration:none} +.btn .timer{font-variant-numeric:tabular-nums;font-weight:500;opacity:.85} +.btn .timer:empty{display:none} +.btn.is-loading{pointer-events:none;opacity:.85} +.btn.is-loading::before{content:"";width:16px;height:16px;flex:none;border-radius:50%; + border:2px solid currentColor;border-right-color:transparent;animation:spin .7s linear infinite} +@keyframes spin{to{transform:rotate(360deg)}} +.actions>*+*{margin-top:10px} + +.alert{padding:12px 14px;border:1px solid;border-radius:8px;font-size:.9375rem;line-height:1.45;margin:0 0 20px} +.alert.error{background:var(--danger-soft);border-color:var(--danger-line);color:var(--danger-ink)} +.alert.notice{background:var(--notice-soft);border-color:var(--notice-line);color:var(--notice-ink)} + +input.code{height:56px;text-align:center;font-size:1.5rem;font-weight:600;letter-spacing:.35em; + font-variant-numeric:tabular-nums;padding-left:.35em} +input.code.has-error{border-color:var(--danger)} +.otp{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:8px} +.otp input{height:56px;padding:0;text-align:center;font-size:1.375rem;font-weight:600; + font-variant-numeric:tabular-nums;caret-color:var(--primary)} +.otp.has-error input{border-color:var(--danger)} +.otp.shake{animation:shake .32s ease-in-out} +@keyframes shake{20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}} +.hint{font-size:.8125rem;color:var(--muted);margin:8px 0 20px} + +.rule{border:0;border-top:1px solid var(--line);margin:28px 0 20px} +.links{margin:20px 0 0;text-align:center;font-size:.875rem;color:var(--muted)} + +@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}} +`; + +const ICONS = { + lock: '', + mail: '', + clock: '', + alert: '', + check: '', +} as const; + +export type Icon = keyof typeof ICONS; + +export function icon(name: Icon, tone: "" | "danger" | "success" = ""): string { + return ``; +} + +export function layout(title: string, body: string): string { + return ` + + + + + + +${escapeHtml(title)} + + + +
+ +
+${body} +
+
+ + +`; +} + +/** `message` is plain text and is escaped here. */ +export function alert(kind: "error" | "notice", message: string | undefined): string { + if (!message) return ""; + const role = kind === "error" ? "alert" : "status"; + return `

${escapeHtml(message)}

`; +} diff --git a/PasswordlessLogin/manishgupta412/tests/config.test.ts b/PasswordlessLogin/manishgupta412/tests/config.test.ts new file mode 100644 index 000000000..1fbf446e8 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/tests/config.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { ConfigError, loadConfig } from "../src/config.js"; + +const valid = { + MAILTRAP_TOKEN: "tok_supersecretvalue", + MAIL_FROM_EMAIL: "login@example.com", + MAIL_FROM_NAME: "Example", + APP_URL: "https://app.example.com/", + PORT: "4000", + OTP_SECRET: "x".repeat(48), + AUTO_SIGNUP: "true", +}; + +describe("config", () => { + it("parses a valid environment", () => { + const c = loadConfig(valid); + expect(c.appUrl).toBe("https://app.example.com"); + expect(c.secureCookies).toBe(true); + expect(c.port).toBe(4000); + expect(c.autoSignup).toBe(true); + expect(c.databasePath).toBe("./data/app.db"); + }); + + it("fails fast listing missing/invalid vars without echoing values", () => { + const env = { ...valid, MAILTRAP_TOKEN: "", OTP_SECRET: "short-secret-value", APP_URL: "ftp://nope" }; + expect(() => loadConfig(env)).toThrow(ConfigError); + try { + loadConfig(env); + } catch (err) { + const msg = (err as Error).message; + expect(msg).toContain("MAILTRAP_TOKEN: is missing"); + expect(msg).toContain("OTP_SECRET: must be at least 32 characters"); + expect(msg).toContain("APP_URL"); + expect(msg).not.toContain("short-secret-value"); + expect(msg).not.toContain("ftp://nope"); + } + }); +}); diff --git a/PasswordlessLogin/manishgupta412/tests/helpers.ts b/PasswordlessLogin/manishgupta412/tests/helpers.ts new file mode 100644 index 000000000..7c46b8e51 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/tests/helpers.ts @@ -0,0 +1,94 @@ +import request from "supertest"; +import { createApp, type AppConfig } from "../src/app.js"; +import type { Clock } from "../src/clock.js"; +import { openDatabase, type DB } from "../src/db.js"; +import type { LoginEmail, Mailer } from "../src/email/mailer.js"; +import { renderLoginEmail, type RenderedEmail } from "../src/email/templates.js"; +import { createLogger } from "../src/logger.js"; + +export class FakeClock implements Clock { + constructor(public t = Date.UTC(2026, 0, 1, 12, 0, 0)) {} + now(): number { + return this.t; + } + advance(ms: number): void { + this.t += ms; + } +} + +export type SentEmail = LoginEmail & RenderedEmail; + +/** Captures the rendered payload instead of calling Mailtrap. */ +export class FakeMailer implements Mailer { + sent: SentEmail[] = []; + fail = false; + + async sendLoginEmail(msg: LoginEmail): Promise { + if (this.fail) { + const secret = msg.method === "code" ? msg.code : msg.link; + throw new Error(`Mailtrap API error while sending ${secret}`); + } + this.sent.push({ ...msg, ...renderLoginEmail(msg) }); + } + + last(): SentEmail { + const mail = this.sent.at(-1); + if (!mail) throw new Error("no email sent"); + return mail; + } +} + +/** The code is the first line of the plain-text part of a code email. */ +export function codeFrom(mail: SentEmail): string { + if (mail.method !== "code") throw new Error("not a code email"); + const first = mail.text.split("\n")[0]!; + if (!/^\d{6}$/.test(first)) throw new Error("code is not on the first line"); + return first; +} + +/** Path + query of the magic link found in the plain-text part. */ +export function linkFrom(mail: SentEmail): { path: string; token: string } { + const match = mail.text.match(/https?:\/\/\S+\/login\/link\?token=([A-Za-z0-9_-]+)/); + if (!match) throw new Error("no link in email"); + const url = new URL(match[0]); + return { path: url.pathname + url.search, token: match[1]! }; +} + +export function wrongCode(code: string): string { + return ((Number(code) + 1) % 1_000_000).toString().padStart(6, "0"); +} + +export const KNOWN = "alice@example.com"; +export const UNKNOWN = "carol@example.com"; + +export function setup(opts: { autoSignup?: boolean; users?: string[]; minResponseMs?: number } = {}) { + const db: DB = openDatabase(":memory:"); + const clock = new FakeClock(); + const mailer = new FakeMailer(); + const logs: string[] = []; + const logger = createLogger((line) => logs.push(line)); + const config: AppConfig = { + appUrl: "http://localhost:3000", + otpSecret: "test-secret-test-secret-test-secret-1234", + autoSignup: opts.autoSignup ?? false, + trustProxy: false, + secureCookies: false, + }; + for (const email of opts.users ?? [KNOWN]) { + db.prepare("INSERT INTO users (email, created_at) VALUES (?, ?)").run(email, clock.now()); + } + const app = createApp({ config, db, mailer, clock, logger, minResponseMs: opts.minResponseMs ?? 0 }); + + const requestCode = (email: string, agent = request(app)) => + agent.post("/login").type("form").send({ email, method: "code" }); + const requestLink = (email: string, agent = request(app)) => + agent.post("/login").type("form").send({ email, method: "link" }); + const postLink = (token: string, agent = request(app)) => + agent.post("/login/link").type("form").send({ token }); + const verify = (email: string, code: string, agent = request(app)) => + agent.post("/login/verify").type("form").send({ email, code }); + const count = (table: string) => + (db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n; + + return { db, clock, mailer, logs, requestCode, requestLink, postLink, verify, count, agent: () => request.agent(app) }; +} diff --git a/PasswordlessLogin/manishgupta412/tests/logger.test.ts b/PasswordlessLogin/manishgupta412/tests/logger.test.ts new file mode 100644 index 000000000..e4a0dcc24 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/tests/logger.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { createLogger, maskEmail, redact } from "../src/logger.js"; + +describe("logger", () => { + it("redacts sensitive fields at any depth", () => { + const out = redact({ + code: "123456", + nested: { token: "abc", link: "https://x/login/link?token=abc", list: [{ html: "

123456

" }] }, + text: "body", + subject: "123456 is your login code", + safe: "ok", + }); + expect(out).toEqual({ + code: "[REDACTED]", + nested: { token: "[REDACTED]", link: "[REDACTED]", list: [{ html: "[REDACTED]" }] }, + text: "[REDACTED]", + subject: "[REDACTED]", + safe: "ok", + }); + }); + + it("scrubs tokens and codes from free-form strings and errors", () => { + const lines: string[] = []; + const log = createLogger((l) => lines.push(l)); + log.error("send failed", { error: new Error("bad request for https://app/login/link?token=SECRET123 code 654321") }); + expect(lines[0]).not.toContain("SECRET123"); + expect(lines[0]).not.toContain("654321"); + expect(lines[0]).toContain("[REDACTED]"); + }); + + it("masks emails", () => { + expect(maskEmail("alice@example.com")).toBe("a***@example.com"); + }); +}); diff --git a/PasswordlessLogin/manishgupta412/tests/login.test.ts b/PasswordlessLogin/manishgupta412/tests/login.test.ts new file mode 100644 index 000000000..378e75c71 --- /dev/null +++ b/PasswordlessLogin/manishgupta412/tests/login.test.ts @@ -0,0 +1,537 @@ +import { describe, expect, it } from "vitest"; +import { MAX_ATTEMPTS } from "../src/auth/challenges.js"; +import { RATE_LIMITS } from "../src/auth/rateLimit.js"; +import { codeFrom, KNOWN, linkFrom, setup, UNKNOWN, wrongCode } from "./helpers.js"; + +const MINUTE = 60_000; +const COOLDOWN = RATE_LIMITS.resendCooldownMs + 1000; + +describe("choosing code or link", () => { + it("offers both choices on the login page", async () => { + const t = setup(); + const res = await t.agent().get("/login"); + expect(res.text).toContain('name="method" value="code"'); + expect(res.text).toContain('name="method" value="link"'); + }); + + it("a code request emails only a code", async () => { + const t = setup(); + const res = await t.requestCode(KNOWN); + expect(res.text).toContain("a 6-digit code"); + expect(res.text).toContain('action="/login/verify"'); + const mail = t.mailer.last(); + expect(mail.method).toBe("code"); + expect(mail.text).not.toContain("/login/link"); + expect(mail.html).not.toContain("/login/link"); + expect(t.db.prepare("SELECT method, token_hash FROM login_challenges").get()).toEqual({ + method: "code", + token_hash: null, + }); + }); + + it("a link request emails only a link, with no code form on the page", async () => { + const t = setup(); + const res = await t.requestLink(KNOWN); + expect(res.text).toContain("a magic link"); + expect(res.text).not.toContain('action="/login/verify"'); + const mail = t.mailer.last(); + expect(mail.method).toBe("link"); + expect(mail.subject).toBe("Your magic link"); + expect(mail.text).not.toMatch(/\b\d{6}\b/); + expect(t.db.prepare("SELECT method, code_hash FROM login_challenges").get()).toEqual({ + method: "link", + code_hash: null, + }); + }); + + it("defaults to a code when no method is given", async () => { + const t = setup(); + await t.agent().post("/login").type("form").send({ email: KNOWN }); + expect(t.mailer.last().method).toBe("code"); + }); + + it("a link-only challenge cannot be completed with a guessed code", async () => { + const t = setup(); + await t.requestLink(KNOWN); + const res = await t.verify(KNOWN, "123456"); + expect(res.status).toBe(400); + expect(res.text).toContain("no active code"); + expect(t.db.prepare("SELECT attempts FROM login_challenges").get()).toEqual({ attempts: 0 }); + }); + + it("lets the user switch method from the check-email page", async () => { + const t = setup(); + const page = await t.requestCode(KNOWN); + expect(page.text).toContain("Email me a magic link instead"); + t.clock.advance(COOLDOWN); + const res = await t.agent().post("/login/resend").type("form").send({ email: KNOWN, method: "link" }); + expect(res.status).toBe(200); + expect(t.mailer.last().method).toBe("link"); + }); +}); + +describe("login via code", () => { + it("emails a code and signs the user in", async () => { + const t = setup(); + const agent = t.agent(); + + const res = await t.requestCode(" Alice@Example.COM ", agent); + expect(res.status).toBe(200); + expect(res.text).toContain("Check your email"); + expect(res.text).toContain(`name="email" value="${KNOWN}"`); + + expect(t.mailer.sent).toHaveLength(1); + const mail = t.mailer.last(); + const code = codeFrom(mail); + expect(mail.to).toBe(KNOWN); + expect(mail.subject).toBe(`${code} is your login code`); + expect(mail.html).toContain(code); + + const verify = await t.verify(KNOWN, code, agent); + expect(verify.status).toBe(303); + expect(verify.headers.location).toBe("/dashboard"); + const cookie = String(verify.headers["set-cookie"]); + expect(cookie).toMatch(/sid=[A-Za-z0-9_-]{43}/); + expect(cookie).toContain("HttpOnly"); + expect(cookie).toContain("SameSite=Lax"); + expect(cookie).toContain("Path=/"); + expect(cookie).not.toContain("Secure"); + + const dash = await agent.get("/dashboard"); + expect(dash.status).toBe(200); + expect(dash.text).toContain(KNOWN); + }); + + it("accepts a code typed with spaces", async () => { + const t = setup(); + await t.requestCode(KNOWN); + const code = codeFrom(t.mailer.last()); + const res = await t.verify(KNOWN, ` ${code.slice(0, 3)} ${code.slice(3)} `); + expect(res.status).toBe(303); + }); +}); + +describe("login via magic link", () => { + it("does not consume the token on GET, signs in on POST", async () => { + const t = setup(); + const agent = t.agent(); + await t.requestLink(KNOWN, agent); + const { path, token } = linkFrom(t.mailer.last()); + + const get = await agent.get(path); + expect(get.status).toBe(200); + expect(get.text).toContain("Continue signing in"); + expect(get.text).toContain('action="/login/link"'); + // A second GET (e.g. an email scanner prefetch) changes nothing. + await agent.get(path); + expect(t.db.prepare("SELECT used_at FROM login_challenges").get()).toEqual({ used_at: null }); + + const post = await t.postLink(token, agent); + expect(post.status).toBe(303); + expect(post.headers.location).toBe("/dashboard"); + + const dash = await agent.get("/dashboard"); + expect(dash.status).toBe(200); + expect(dash.text).toContain(KNOWN); + }); + + it("rejects an invalid token", async () => { + const t = setup(); + const res = await t.postLink("A".repeat(43)); + expect(res.status).toBe(400); + expect(res.text).toContain("This magic link isn't valid."); + }); +}); + +describe("expiry and single use", () => { + it("rejects an expired code and offers a new one", async () => { + const t = setup(); + await t.requestCode(KNOWN); + const code = codeFrom(t.mailer.last()); + t.clock.advance(10 * MINUTE + 1000); + + const res = await t.verify(KNOWN, code); + expect(res.status).toBe(400); + expect(res.text).toContain("This code has expired."); + expect(res.text).toContain('action="/login/resend"'); + expect(res.text).toContain("Send a new code"); + expect(res.headers["set-cookie"]).toBeUndefined(); + }); + + it("rejects an expired link and offers a new link", async () => { + const t = setup(); + await t.requestLink(KNOWN); + const { token } = linkFrom(t.mailer.last()); + t.clock.advance(11 * MINUTE); + const res = await t.postLink(token); + expect(res.text).toContain("This magic link has expired."); + expect(res.text).toContain('name="method" value="link"'); + expect(res.text).toContain("Send a new link"); + }); + + it("rejects a reused code", async () => { + const t = setup(); + await t.requestCode(KNOWN); + const code = codeFrom(t.mailer.last()); + expect((await t.verify(KNOWN, code)).status).toBe(303); + + const again = await t.verify(KNOWN, code); + expect(again.status).toBe(400); + expect(again.text).toContain("This code has already been used."); + expect(again.text).toContain('action="/login/resend"'); + }); + + it("rejects a reused link", async () => { + const t = setup(); + await t.requestLink(KNOWN); + const { token } = linkFrom(t.mailer.last()); + expect((await t.postLink(token)).status).toBe(303); + + const again = await t.postLink(token); + expect(again.status).toBe(400); + expect(again.text).toContain("This magic link has already been used."); + }); + + it("invalidates the previous code or link when a new one is requested", async () => { + const t = setup(); + await t.requestLink(KNOWN); + const oldLink = linkFrom(t.mailer.last()).token; + t.clock.advance(COOLDOWN); + await t.requestCode(KNOWN); + const oldCode = codeFrom(t.mailer.last()); + t.clock.advance(COOLDOWN); + await t.agent().post("/login/resend").type("form").send({ email: KNOWN, method: "code" }); + expect(t.mailer.sent).toHaveLength(3); + + expect((await t.postLink(oldLink)).text).toContain("replaced by a newer one"); + const stale = await t.verify(KNOWN, oldCode); + expect(stale.status).toBe(401); + + const fresh = await t.verify(KNOWN, codeFrom(t.mailer.last())); + expect(fresh.status).toBe(303); + }); +}); + +describe("attempt limits", () => { + it(`locks after ${MAX_ATTEMPTS} wrong codes, even for the correct code`, async () => { + const t = setup(); + await t.requestCode(KNOWN); + const code = codeFrom(t.mailer.last()); + const bad = wrongCode(code); + + for (let i = 1; i < MAX_ATTEMPTS; i++) { + const res = await t.verify(KNOWN, bad); + expect(res.status).toBe(401); + expect(res.text).toContain(`You have ${MAX_ATTEMPTS - i} attempt`); + } + const last = await t.verify(KNOWN, bad); + expect(last.text).toContain("Too many incorrect attempts"); + + const correct = await t.verify(KNOWN, code); + expect(correct.status).toBe(400); + expect(correct.text).toContain("Too many incorrect attempts"); + expect(correct.text).toContain('action="/login/resend"'); + expect(correct.headers["set-cookie"]).toBeUndefined(); + expect(t.count("sessions")).toBe(0); + }); + + it("does not count malformed codes as attempts", async () => { + const t = setup(); + await t.requestCode(KNOWN); + const res = await t.verify(KNOWN, "12ab"); + expect(res.status).toBe(400); + expect(res.text).toContain("Enter the 6-digit code"); + expect(t.db.prepare("SELECT attempts FROM login_challenges").get()).toEqual({ attempts: 0 }); + }); +}); + +describe("cooldown and rate limits", () => { + it("enforces a 60 second resend cooldown, whichever method is chosen", async () => { + const t = setup(); + expect((await t.requestCode(KNOWN)).status).toBe(200); + + const tooSoon = await t.agent().post("/login/resend").type("form").send({ email: KNOWN, method: "code" }); + expect(tooSoon.status).toBe(429); + expect(tooSoon.text).toContain("when the timer runs out"); + // The cooldown page must not claim that anything was sent. + expect(tooSoon.text).toContain("less than a minute ago."); + expect(tooSoon.text).not.toContain("Check your email"); + expect(tooSoon.text).toContain('action="/login/verify"'); + const linkTooSoon = await t.requestLink(KNOWN); + expect(linkTooSoon.text).toContain("request a new magic link when the timer runs out"); + expect(linkTooSoon.text).not.toContain('action="/login/verify"'); + expect((await t.requestLink(KNOWN)).status).toBe(429); + expect(t.mailer.sent).toHaveLength(1); + + t.clock.advance(59_000); + expect((await t.requestCode(KNOWN)).status).toBe(429); + + t.clock.advance(2_000); + expect((await t.requestLink(KNOWN)).status).toBe(200); + expect(t.mailer.sent).toHaveLength(2); + }); + + it("renders resend buttons with the remaining cooldown for the live timer", async () => { + const t = setup(); + const first = await t.requestCode(KNOWN); + expect(first.text).toContain('data-wait="60"'); + expect(first.text).toContain(''); + // Both the resend and the switch-method button wait for the cooldown. + expect(first.text.match(/data-wait="60"/g)).toHaveLength(2); + + t.clock.advance(20_000); + const early = await t.requestLink(KNOWN); + expect(early.status).toBe(429); + expect(early.text).toContain('data-wait="40"'); + + // A wrong code re-renders the page with the cooldown still counting down. + t.clock.advance(5_000); + const wrong = await t.verify(KNOWN, wrongCode(codeFrom(t.mailer.last()))); + expect(wrong.text).toContain('data-wait="35"'); + + t.clock.advance(35_000); + const wrongAgain = await t.verify(KNOWN, wrongCode(codeFrom(t.mailer.last()))); + expect(wrongAgain.text).not.toContain("data-wait"); + }); + + it("does not show a timer after a failed send (retry is allowed at once)", async () => { + const t = setup(); + t.mailer.fail = true; + const res = await t.requestCode(KNOWN); + expect(res.text).not.toContain("data-wait"); + }); + + it("serves the countdown script from the same origin under the CSP", async () => { + const t = setup(); + const res = await t.agent().get("/assets/app.js"); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("javascript"); + expect(() => new Function(res.text)).not.toThrow(); + const page = await t.agent().get("/login"); + expect(page.headers["content-security-policy"]).toContain("script-src 'self'"); + }); + + it("limits requests per email", async () => { + const t = setup(); + const { max, windowMs } = RATE_LIMITS.requestPerEmail; + for (let i = 0; i < max; i++) { + const res = i % 2 ? await t.requestLink(KNOWN) : await t.requestCode(KNOWN); + expect(res.status).toBe(200); + t.clock.advance(COOLDOWN); + } + const blocked = await t.requestCode(KNOWN); + expect(blocked.status).toBe(429); + expect(blocked.text).toContain("Too many sign-in attempts"); + expect(t.mailer.sent).toHaveLength(max); + + t.clock.advance(windowMs); + expect((await t.requestCode(KNOWN)).status).toBe(200); + }); + + it("limits requests per IP", async () => { + const users = Array.from({ length: 25 }, (_, i) => `user${i}@example.com`); + const t = setup({ users }); + const { max } = RATE_LIMITS.requestPerIp; + for (let i = 0; i < max; i++) { + expect((await t.requestCode(users[i]!)).status).toBe(200); + } + const blocked = await t.requestLink(users[max]!); + expect(blocked.status).toBe(429); + expect(blocked.text).toContain("Too many sign-in attempts"); + expect(t.mailer.sent).toHaveLength(max); + }); +}); + +describe("send failure", () => { + it.each(["code", "link"] as const)( + "%s: shows an error, stores no challenge, and allows an immediate retry", + async (method) => { + const t = setup(); + t.mailer.fail = true; + const res = method === "code" ? await t.requestCode(KNOWN) : await t.requestLink(KNOWN); + expect(res.status).toBe(502); + expect(res.text).toContain("We couldn't send the email, try again"); + expect(res.text).toContain(`name="method" value="${method}"`); + expect(t.count("login_challenges")).toBe(0); + + t.mailer.fail = false; + const retry = await t.agent().post("/login/resend").type("form").send({ email: KNOWN, method }); + expect(retry.status).toBe(200); + expect(t.count("login_challenges")).toBe(1); + }, + ); +}); + +describe("no account enumeration", () => { + const normalize = (body: string, email: string) => body.replaceAll(email, "EMAIL"); + + it.each(["code", "link"] as const)("%s: known and unknown emails get identical responses", async (method) => { + const t = setup({ minResponseMs: 150 }); + const send = (email: string) => (method === "code" ? t.requestCode(email) : t.requestLink(email)); + + let started = Date.now(); + const known = await send(KNOWN); + const knownMs = Date.now() - started; + started = Date.now(); + const unknown = await send(UNKNOWN); + const unknownMs = Date.now() - started; + + expect(unknown.status).toBe(known.status); + expect(normalize(unknown.text, UNKNOWN)).toBe(normalize(known.text, KNOWN)); + expect(knownMs).toBeGreaterThanOrEqual(140); + expect(unknownMs).toBeGreaterThanOrEqual(140); + + // Unknown: nothing sent, nothing stored. + expect(t.mailer.sent.map((m) => m.to)).toEqual([KNOWN]); + expect(t.db.prepare("SELECT COUNT(*) AS n FROM login_challenges WHERE email = ?").get(UNKNOWN)).toEqual({ n: 0 }); + expect(t.db.prepare("SELECT COUNT(*) AS n FROM users WHERE email = ?").get(UNKNOWN)).toEqual({ n: 0 }); + + // Cooldown applies to unknown emails too, with the same response. + const knownAgain = await send(KNOWN); + const unknownAgain = await send(UNKNOWN); + expect(unknownAgain.status).toBe(429); + expect(knownAgain.status).toBe(429); + expect(normalize(unknownAgain.text, UNKNOWN)).toBe(normalize(knownAgain.text, KNOWN)); + }); + + it("rate limits unknown emails exactly like known ones", async () => { + const t = setup(); + const { max } = RATE_LIMITS.requestPerEmail; + for (let i = 0; i <= max; i++) { + const k = await t.requestCode(KNOWN); + const u = await t.requestCode(UNKNOWN); + expect(u.status).toBe(k.status); + t.clock.advance(COOLDOWN); + } + expect((await t.requestCode(UNKNOWN)).status).toBe(429); + }); + + it("gives unknown emails the same verify response as an email without a code", async () => { + const t = setup({ users: [KNOWN, "bob@example.com"] }); + const unknown = await t.verify(UNKNOWN, "123456"); + const known = await t.verify("bob@example.com", "123456"); + expect(unknown.status).toBe(known.status); + }); +}); + +describe("AUTO_SIGNUP", () => { + it("creates the user on first successful login only", async () => { + const t = setup({ autoSignup: true, users: [] }); + await t.requestLink(UNKNOWN); + expect(t.mailer.sent).toHaveLength(1); + expect(t.count("users")).toBe(0); + + const res = await t.postLink(linkFrom(t.mailer.last()).token); + expect(res.status).toBe(303); + expect(t.db.prepare("SELECT email FROM users").all()).toEqual([{ email: UNKNOWN }]); + }); +}); + +describe("secrets never stored or logged in plaintext", () => { + it("keeps codes, tokens, links and session ids out of the DB and logs", async () => { + const t = setup(); + const agent = t.agent(); + + await t.requestCode(KNOWN, agent); + const code = codeFrom(t.mailer.last()); + await t.verify(KNOWN, wrongCode(code), agent); + const viaCode = await t.verify(KNOWN, code, agent); + const sid1 = String(viaCode.headers["set-cookie"]).match(/sid=([^;]+)/)![1]!; + + t.clock.advance(COOLDOWN); + await t.requestLink(KNOWN, agent); + const linkMail = t.mailer.last(); + if (linkMail.method !== "link") throw new Error("expected a link email"); + const { path, token } = linkFrom(linkMail); + await agent.get(path); + const viaLink = await t.postLink(token, agent); + const sid2 = String(viaLink.headers["set-cookie"]).match(/sid=([^;]+)/)![1]!; + + // A failing send logs the provider error, which here includes the link. + t.clock.advance(COOLDOWN); + t.mailer.fail = true; + await t.requestLink(KNOWN); + t.clock.advance(COOLDOWN); + await t.requestCode(KNOWN); + + const dump = JSON.stringify( + ["users", "login_challenges", "sessions", "rate_limits"].map((table) => + t.db.prepare(`SELECT * FROM ${table}`).all(), + ), + ); + for (const secret of [code, token, linkMail.link, sid1, sid2]) { + expect(dump).not.toContain(secret); + } + + const logs = t.logs.join("\n"); + expect(logs).toContain("login email send failed"); + for (const secret of [code, token, linkMail.link, sid1, sid2, KNOWN]) { + expect(logs).not.toContain(secret); + } + }); +}); + +describe("sessions", () => { + it("redirects /dashboard to /login without a session", async () => { + const t = setup(); + const res = await t.agent().get("/dashboard"); + expect(res.status).toBe(302); + expect(res.headers.location).toBe("/login"); + + const bogus = await t.agent().get("/dashboard").set("Cookie", "sid=not-a-real-session"); + expect(bogus.status).toBe(302); + }); + + it("logout destroys the session and clears the cookie", async () => { + const t = setup(); + const agent = t.agent(); + await t.requestCode(KNOWN, agent); + const login = await t.verify(KNOWN, codeFrom(t.mailer.last()), agent); + const sid = String(login.headers["set-cookie"]).match(/sid=([^;]+)/)![1]!; + expect(t.count("sessions")).toBe(1); + + const out = await agent.post("/logout"); + expect(out.status).toBe(303); + expect(out.headers.location).toBe("/login"); + expect(String(out.headers["set-cookie"])).toContain("Max-Age=0"); + expect(t.count("sessions")).toBe(0); + + // Even replaying the old cookie no longer works. + const replay = await t.agent().get("/dashboard").set("Cookie", `sid=${sid}`); + expect(replay.status).toBe(302); + expect(replay.headers.location).toBe("/login"); + }); + + it("expires sessions after 7 days", async () => { + const t = setup(); + const agent = t.agent(); + await t.requestCode(KNOWN, agent); + await t.verify(KNOWN, codeFrom(t.mailer.last()), agent); + t.clock.advance(7 * 24 * 60 * MINUTE + 1); + expect((await agent.get("/dashboard")).status).toBe(302); + }); +}); + +describe("hardening", () => { + it("escapes user input in HTML", async () => { + const t = setup(); + const res = await t.requestCode('">'); + expect(res.status).toBe(400); + expect(res.text).not.toContain("