diff --git a/apps/redis/README.md b/apps/redis/README.md new file mode 100644 index 0000000000..27fc08f92d --- /dev/null +++ b/apps/redis/README.md @@ -0,0 +1,53 @@ +# Local Redis Stack (Optional) + +This folder wires up a Redis + Serverless Redis HTTP (SRH) stack for local testing. The platform defaults to an in-memory store, so you only start this stack when you want Redis-backed state and Upstash-compatible HTTP access. + +## Quick start + +```bash +# from apps/redis +bun redis:start +``` + +The `redis:start` script runs `docker compose up -d`, which launches Redis on port 6379 and SRH on port 8079. + +## Configure your app + +1. **Point your environment at SRH** + + ```env + UPSTASH_REDIS_REST_URL=http://localhost:8079 + UPSTASH_REDIS_REST_TOKEN=example_token + ``` + + With this configuration your app uses the local SRH proxy instead of the default local memory store. + +2. **Connect to Redis directly (optional)** + + First, find the Redis container name: + + ```bash + docker compose ps + ``` + + Then connect using `docker exec`: + + ```bash + docker exec -it redis-cli + ``` + + Replace `` with the name from `docker compose ps` (typically `redis-redis-1`). From there you can inspect keys, adjust TTLs, and flush state. + +## CI and GitHub Actions + +Use the same `bun redis:start`/`bun redis:stop` pair in CI jobs if you need a deterministic Redis backend. SRH waits until the first request before dialing Redis, so there is no race condition on startup. + +## Tear down + +```bash +bun redis:stop +``` + +This command runs `docker compose down` and removes the containers. + +> **Optional reminder:** if you skip running this stack, the app keeps using the in-memory fallback. Running the stack swaps you over to Redis-based storage so your data lives in the containers instead of memory. \ No newline at end of file diff --git a/apps/redis/docker-compose.yml b/apps/redis/docker-compose.yml new file mode 100644 index 0000000000..64432e9857 --- /dev/null +++ b/apps/redis/docker-compose.yml @@ -0,0 +1,13 @@ +services: + redis: + image: redis + ports: + - "6379:6379" + serverless-redis-http: + ports: + - "8079:80" + image: hiett/serverless-redis-http:latest + environment: + SRH_MODE: env + SRH_TOKEN: example_token + SRH_CONNECTION_STRING: "redis://redis:6379" # Using `redis` hostname since they're in the same Docker network. \ No newline at end of file diff --git a/apps/redis/package.json b/apps/redis/package.json new file mode 100644 index 0000000000..ba35be9faa --- /dev/null +++ b/apps/redis/package.json @@ -0,0 +1,14 @@ +{ + "name": "@tuturuuu/redis", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "redis:start": "docker compose up -d", + "redis:stop": "docker compose down" + }, + "devDependencies": { + "@upstash/cli": "^0.3.0" + }, + "packageManager": "bun@1.3.11" +} diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 1bb45ef2ce..ee131178bf 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -1,6 +1,7 @@ import { match } from '@formatjs/intl-localematcher'; import { updateSession } from '@ncthub/supabase/next/proxy'; import type { SupabaseUser } from '@ncthub/supabase/next/user'; +import { guardApiProxyRequest } from '@ncthub/utils/api-proxy-guard'; import Negotiator from 'negotiator'; import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; @@ -9,12 +10,20 @@ import { LOCALE_COOKIE_NAME, PUBLIC_PATHS } from './constants/common'; import { defaultLocale, type Locale, supportedLocales } from './i18n/routing'; export async function proxy(req: NextRequest): Promise { + if (req.nextUrl.pathname.startsWith('/api')) { + const guardResponse = await guardApiProxyRequest(req, { + prefixBase: 'proxy:web:api', + }); + if (guardResponse) { + return guardResponse; + } + + return NextResponse.next(); + } + // Make sure user session is always refreshed const { res, user } = await updateSession(req); - // If current path starts with /api, return without redirecting - if (req.nextUrl.pathname.startsWith('/api')) return res; - // Handle special cases for public paths const { res: nextRes, redirect } = handleRedirect({ req, res, user }); if (redirect) return nextRes; diff --git a/bun.lock b/bun.lock index e633a849bf..400397a736 100644 --- a/bun.lock +++ b/bun.lock @@ -45,6 +45,13 @@ "typescript": "^5.9.3", }, }, + "apps/redis": { + "name": "@tuturuuu/redis", + "version": "1.0.0", + "devDependencies": { + "@upstash/cli": "^0.3.0", + }, + }, "apps/web": { "name": "@ncthub/web", "version": "0.14.0", @@ -372,6 +379,7 @@ "version": "0.0.1", "dependencies": { "@ncthub/supabase": "workspace:*", + "@upstash/ratelimit": "^2.0.8", "@upstash/redis": "^1.37.0", "clsx": "^2.1.1", "next": "16.2.1", @@ -611,6 +619,10 @@ "@deepgram/sdk": ["@deepgram/sdk@5.0.0", "", { "dependencies": { "ws": "^8.16.0" } }, "sha512-x1wMiOgDGqcLEaQpQBQLTtk5mLbXbYgcBEpp7cfJIyEtqdIGgijCZH+a/esiVp+xIcTYYroTxG47RVppZOHbWw=="], + "@deno/shim-deno": ["@deno/shim-deno@0.5.0", "", { "dependencies": { "@deno/shim-deno-test": "^0.3.2", "which": "^2.0.2" } }, "sha512-bx2TT/WwmpRlnk2Q0qe9xJMzbyKyl7Uyz9cdXEn756Obu15bRanLiVa/3+09Bg8HYL+mUXydWzh32/oxsVv6dw=="], + + "@deno/shim-deno-test": ["@deno/shim-deno-test@0.3.3", "", {}, "sha512-Ge0Tnl7zZY0VvEfgsyLhjid8DzI1d0La0dgm+3m0/A8gZXgp5xwlyIyue5e4SCUuVB/3AH/0lun9LcJhhTwmbg=="], + "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], "@discoveryjs/json-ext": ["@discoveryjs/json-ext@0.5.7", "", {}, "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw=="], @@ -653,57 +665,57 @@ "@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="], "@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="], @@ -1495,6 +1507,8 @@ "@turbo/windows-arm64": ["@turbo/windows-arm64@2.8.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-voicVULvUV5yaGXo0Iue13BcHGYW3u0VgqSbfQwBaHbpj1zLjYV4KIe+7fYIo6DO8FVUJzxFps3ODCQG/Wy2Qw=="], + "@tuturuuu/redis": ["@tuturuuu/redis@workspace:apps/redis"], + "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], @@ -1647,6 +1661,12 @@ "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], + "@upstash/cli": ["@upstash/cli@0.3.0", "", { "dependencies": { "@deno/shim-deno": "~0.5.0", "node-fetch": "latest" }, "bin": { "upstash": "esm/mod.js" } }, "sha512-ugUpNwSfXLn+rDxilJtgPRVTOJj83sOEU8wFdqhafeqNz702rXeiUZO4eE/uxG2ZPQlUrasQfzNgwbkVGfTjKQ=="], + + "@upstash/core-analytics": ["@upstash/core-analytics@0.0.10", "", { "dependencies": { "@upstash/redis": "^1.28.3" } }, "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ=="], + + "@upstash/ratelimit": ["@upstash/ratelimit@2.0.8", "", { "dependencies": { "@upstash/core-analytics": "^0.0.10" }, "peerDependencies": { "@upstash/redis": "^1.34.3" } }, "sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w=="], + "@upstash/redis": ["@upstash/redis@1.37.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-LqOJ3+XWPLSZ2rGSed5DYG3ixybxb8EhZu3yQqF7MdZX1wLBG/FRcI6xcUZXHy/SS7mmXWyadrud0HJHkOc+uw=="], "@use-gesture/core": ["@use-gesture/core@10.3.1", "", {}, "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw=="], @@ -2033,7 +2053,7 @@ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -3223,6 +3243,8 @@ "@react-email/markdown/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "@react-email/preview-server/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "@react-email/preview-server/next": ["next@16.1.7", "", { "dependencies": { "@next/env": "16.1.7", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.7", "@next/swc-darwin-x64": "16.1.7", "@next/swc-linux-arm64-gnu": "16.1.7", "@next/swc-linux-arm64-musl": "16.1.7", "@next/swc-linux-x64-gnu": "16.1.7", "@next/swc-linux-x64-musl": "16.1.7", "@next/swc-win32-arm64-msvc": "16.1.7", "@next/swc-win32-x64-msvc": "16.1.7", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg=="], "@react-email/tailwind/tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], @@ -3333,6 +3355,8 @@ "radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "react-email/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "react-use/@types/js-cookie": ["@types/js-cookie@2.2.7", "", {}, "sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA=="], "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="], @@ -3367,6 +3391,58 @@ "@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + "@react-email/preview-server/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "@react-email/preview-server/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "@react-email/preview-server/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "@react-email/preview-server/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "@react-email/preview-server/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "@react-email/preview-server/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "@react-email/preview-server/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "@react-email/preview-server/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "@react-email/preview-server/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "@react-email/preview-server/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "@react-email/preview-server/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "@react-email/preview-server/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "@react-email/preview-server/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "@react-email/preview-server/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "@react-email/preview-server/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "@react-email/preview-server/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "@react-email/preview-server/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "@react-email/preview-server/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "@react-email/preview-server/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "@react-email/preview-server/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "@react-email/preview-server/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "@react-email/preview-server/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "@react-email/preview-server/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "@react-email/preview-server/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "@react-email/preview-server/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "@react-email/preview-server/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "@react-email/preview-server/next/@next/env": ["@next/env@16.1.7", "", {}, "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg=="], "@react-email/preview-server/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg=="], @@ -3423,6 +3499,58 @@ "parse5-parser-stream/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "react-email/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "react-email/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "react-email/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "react-email/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "react-email/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "react-email/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "react-email/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "react-email/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "react-email/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "react-email/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "react-email/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "react-email/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "react-email/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "react-email/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "react-email/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "react-email/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "react-email/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "react-email/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "react-email/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "react-email/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "react-email/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "react-email/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "react-email/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "react-email/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "react-email/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "react-email/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "@ai-sdk/google-vertex/google-auth-library/gaxios/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "@ai-sdk/google-vertex/google-auth-library/gaxios/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], diff --git a/package.json b/package.json index e75c782cc4..19dac1feff 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "lint:fix": "biome lint --write", "format-and-lint": "biome check", "format-and-lint:fix": "biome check --write", + "redis:start": "cd apps/redis && bun redis:start", + "redis:stop": "cd apps/redis && bun redis:stop", "stop": "cd apps/db && bun sb:stop", "sb:status": "cd apps/db && bun sb:status", "sb:start": "cd apps/db && bun sb:start", diff --git a/packages/utils/package.json b/packages/utils/package.json index bc3c2ce404..c22a7699cc 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -11,6 +11,7 @@ "packageManager": "bun@1.3.11", "dependencies": { "@ncthub/supabase": "workspace:*", + "@upstash/ratelimit": "^2.0.8", "@upstash/redis": "^1.37.0", "clsx": "^2.1.1", "next": "16.2.1", diff --git a/packages/utils/src/abuse-protection/__tests__/edge.test.ts b/packages/utils/src/abuse-protection/__tests__/edge.test.ts new file mode 100644 index 0000000000..f80b9077d2 --- /dev/null +++ b/packages/utils/src/abuse-protection/__tests__/edge.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { extractIPFromRequest } from '../edge'; + +describe('abuse-protection edge', () => { + describe('extractIPFromRequest', () => { + it('prefers cf-connecting-ip over x-forwarded-for', () => { + const headers = new Headers(); + headers.set('cf-connecting-ip', '203.0.113.10'); + headers.set('x-forwarded-for', '198.51.100.10, 10.0.0.1'); + + expect(extractIPFromRequest(headers)).toBe('203.0.113.10'); + }); + + it('falls back to true-client-ip before generic proxy headers', () => { + const headers = new Headers(); + headers.set('true-client-ip', '203.0.113.20'); + headers.set('x-forwarded-for', '198.51.100.20, 10.0.0.1'); + + expect(extractIPFromRequest(headers)).toBe('203.0.113.20'); + }); + + it('falls back to x-forwarded-for when cloud proxy headers are absent', () => { + const headers = new Headers(); + headers.set('x-forwarded-for', '198.51.100.30, 10.0.0.1'); + + expect(extractIPFromRequest(headers)).toBe('198.51.100.30'); + }); + }); +}); diff --git a/packages/utils/src/abuse-protection/__tests__/index.test.ts b/packages/utils/src/abuse-protection/__tests__/index.test.ts new file mode 100644 index 0000000000..ab18bbfb73 --- /dev/null +++ b/packages/utils/src/abuse-protection/__tests__/index.test.ts @@ -0,0 +1,431 @@ +/** + * Unit tests for OTP Abuse Protection System + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + ABUSE_THRESHOLDS, + BLOCK_DURATIONS, + checkOTPSendLimit, + extractIPFromHeaders, + hashEmail, + MAX_BLOCK_LEVEL, + REDIS_KEYS, + WINDOW_MS, +} from '../index'; + +describe('abuse-protection', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + describe('extractIPFromHeaders', () => { + it('should extract IP from x-forwarded-for header', () => { + const headers = new Headers(); + headers.set('x-forwarded-for', '192.168.1.1, 10.0.0.1'); + expect(extractIPFromHeaders(headers)).toBe('192.168.1.1'); + }); + + it('should extract single IP from x-forwarded-for', () => { + const headers = new Headers(); + headers.set('x-forwarded-for', '203.0.113.50'); + expect(extractIPFromHeaders(headers)).toBe('203.0.113.50'); + }); + + it('should extract IP from x-real-ip header', () => { + const headers = new Headers(); + headers.set('x-real-ip', '192.168.1.100'); + expect(extractIPFromHeaders(headers)).toBe('192.168.1.100'); + }); + + it('should extract IP from cf-connecting-ip header (Cloudflare)', () => { + const headers = new Headers(); + headers.set('cf-connecting-ip', '172.16.0.1'); + expect(extractIPFromHeaders(headers)).toBe('172.16.0.1'); + }); + + it('should prefer cf-connecting-ip over forwarded proxy headers', () => { + const headers = new Headers(); + headers.set('x-forwarded-for', '192.168.1.1'); + headers.set('x-real-ip', '192.168.1.2'); + headers.set('cf-connecting-ip', '192.168.1.3'); + expect(extractIPFromHeaders(headers)).toBe('192.168.1.3'); + }); + + it('should prefer true-client-ip when cf-connecting-ip is absent', () => { + const headers = new Headers(); + headers.set('x-forwarded-for', '192.168.1.1'); + headers.set('x-real-ip', '192.168.1.2'); + headers.set('true-client-ip', '192.168.1.4'); + expect(extractIPFromHeaders(headers)).toBe('192.168.1.4'); + }); + + it('should fall back to x-forwarded-for if explicit client IP headers are invalid', () => { + const headers = new Headers(); + headers.set('cf-connecting-ip', 'invalid-ip'); + headers.set('true-client-ip', 'also-invalid'); + headers.set('x-forwarded-for', '192.168.1.100, 10.0.0.1'); + expect(extractIPFromHeaders(headers)).toBe('192.168.1.100'); + }); + + it('should return "unknown" when no valid IP is found', () => { + const headers = new Headers(); + expect(extractIPFromHeaders(headers)).toBe('unknown'); + }); + + it('should return "unknown" for invalid IP formats', () => { + const headers = new Headers(); + headers.set('x-forwarded-for', 'not-an-ip'); + headers.set('x-real-ip', 'also-invalid'); + headers.set('cf-connecting-ip', 'still.not.valid'); + expect(extractIPFromHeaders(headers)).toBe('unknown'); + }); + + it('should accept IPs with values matching format regex', () => { + // Note: The regex validates format, not IPv4 value ranges + // 999.999.999.999 matches the pattern \d{1,3}.\d{1,3}.\d{1,3}.\d{1,3} + const headers = new Headers(); + headers.set('x-forwarded-for', '999.999.999.999'); + expect(extractIPFromHeaders(headers)).toBe('999.999.999.999'); + }); + + it('should work with Map-based headers', () => { + const headers = new Map(); + headers.set('x-forwarded-for', '10.0.0.5'); + expect(extractIPFromHeaders(headers)).toBe('10.0.0.5'); + }); + + it('should work with plain object headers', () => { + const headers: Record = { + 'x-forwarded-for': '10.0.0.10', + }; + expect(extractIPFromHeaders(headers)).toBe('10.0.0.10'); + }); + + it('should handle IPv6 addresses', () => { + const headers = new Headers(); + headers.set('x-forwarded-for', '2001:db8::1'); + expect(extractIPFromHeaders(headers)).toBe('2001:db8::1'); + }); + + it('should handle IPv6 in x-real-ip', () => { + const headers = new Headers(); + headers.set('x-real-ip', '::1'); + expect(extractIPFromHeaders(headers)).toBe('::1'); + }); + + it('should handle whitespace in IP list', () => { + const headers = new Headers(); + headers.set('x-forwarded-for', ' 192.168.1.1 , 10.0.0.1'); + expect(extractIPFromHeaders(headers)).toBe('192.168.1.1'); + }); + }); + + describe('hashEmail', () => { + it('should return a 16-character hex string', () => { + const hash = hashEmail('test@example.com'); + expect(hash).toMatch(/^[a-f0-9]{16}$/); + }); + + it('should be case-insensitive', () => { + const hash1 = hashEmail('Test@Example.COM'); + const hash2 = hashEmail('test@example.com'); + expect(hash1).toBe(hash2); + }); + + it('should produce different hashes for different emails', () => { + const hash1 = hashEmail('user1@example.com'); + const hash2 = hashEmail('user2@example.com'); + expect(hash1).not.toBe(hash2); + }); + + it('should be deterministic', () => { + const email = 'consistent@test.com'; + const hash1 = hashEmail(email); + const hash2 = hashEmail(email); + expect(hash1).toBe(hash2); + }); + + it('should handle special characters in email', () => { + const hash = hashEmail('user+tag@example.com'); + expect(hash).toMatch(/^[a-f0-9]{16}$/); + }); + + it('should handle unicode in email', () => { + const hash = hashEmail('用户@example.com'); + expect(hash).toMatch(/^[a-f0-9]{16}$/); + }); + }); + + describe('ABUSE_THRESHOLDS constants', () => { + it('should have valid OTP send limits', () => { + expect(ABUSE_THRESHOLDS.OTP_SEND_PER_MINUTE).toBe(3); + expect(ABUSE_THRESHOLDS.OTP_SEND_PER_HOUR).toBe(10); + expect(ABUSE_THRESHOLDS.OTP_SEND_PER_DAY).toBe(12); + expect(ABUSE_THRESHOLDS.OTP_SEND_EMAIL_COOLDOWN_WINDOW_MS).toBe( + 15 * 60 * 1000 + ); + expect(ABUSE_THRESHOLDS.OTP_SEND_EMAIL_PER_HOUR).toBe(2); + expect(ABUSE_THRESHOLDS.OTP_SEND_EMAIL_PER_DAY).toBe(4); + }); + + it('should have valid OTP verify limits', () => { + expect(ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_WINDOW_MS).toBe(5 * 60 * 1000); + expect(ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_MAX).toBe(5); + expect(ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_EMAIL_WINDOW_MS).toBe( + 15 * 60 * 1000 + ); + expect(ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_EMAIL_MAX).toBe(10); + }); + + it('should have valid MFA limits', () => { + expect(ABUSE_THRESHOLDS.MFA_CHALLENGE_PER_MINUTE).toBe(5); + expect(ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_WINDOW_MS).toBe(5 * 60 * 1000); + expect(ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_MAX).toBe(5); + }); + + it('should have valid reauth limits', () => { + expect(ABUSE_THRESHOLDS.REAUTH_SEND_PER_MINUTE).toBe(3); + expect(ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_WINDOW_MS).toBe( + 5 * 60 * 1000 + ); + expect(ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_MAX).toBe(5); + }); + + it('should have valid password login limits', () => { + expect(ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_WINDOW_MS).toBe( + 5 * 60 * 1000 + ); + expect(ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX).toBe(10); + }); + }); + + describe('BLOCK_DURATIONS constants', () => { + it('should have level 1 at 5 minutes', () => { + expect(BLOCK_DURATIONS[1]).toBe(5 * 60); + }); + + it('should have level 2 at 15 minutes', () => { + expect(BLOCK_DURATIONS[2]).toBe(15 * 60); + }); + + it('should have level 3 at 1 hour', () => { + expect(BLOCK_DURATIONS[3]).toBe(60 * 60); + }); + + it('should have level 4 at 24 hours', () => { + expect(BLOCK_DURATIONS[4]).toBe(24 * 60 * 60); + }); + + it('should have progressively increasing durations', () => { + expect(BLOCK_DURATIONS[1]).toBeLessThan(BLOCK_DURATIONS[2]); + expect(BLOCK_DURATIONS[2]).toBeLessThan(BLOCK_DURATIONS[3]); + expect(BLOCK_DURATIONS[3]).toBeLessThan(BLOCK_DURATIONS[4]); + }); + }); + + describe('MAX_BLOCK_LEVEL constant', () => { + it('should be 4', () => { + expect(MAX_BLOCK_LEVEL).toBe(4); + }); + }); + + describe('REDIS_KEYS', () => { + const testIP = '192.168.1.1'; + const testEmailHash = 'abc123'; + + it('should generate correct OTP send keys', () => { + expect(REDIS_KEYS.OTP_SEND(testIP)).toBe(`otp:send:${testIP}`); + expect(REDIS_KEYS.OTP_SEND_HOURLY(testIP)).toBe( + `otp:send:hourly:${testIP}` + ); + expect(REDIS_KEYS.OTP_SEND_DAILY(testIP)).toBe( + `otp:send:daily:${testIP}` + ); + expect(REDIS_KEYS.OTP_SEND_EMAIL_COOLDOWN(testEmailHash)).toBe( + `otp:send:email:cooldown:${testEmailHash}` + ); + expect(REDIS_KEYS.OTP_SEND_EMAIL_HOURLY(testEmailHash)).toBe( + `otp:send:email:hourly:${testEmailHash}` + ); + expect(REDIS_KEYS.OTP_SEND_EMAIL_DAILY(testEmailHash)).toBe( + `otp:send:email:daily:${testEmailHash}` + ); + }); + + it('should generate correct OTP verify keys', () => { + expect(REDIS_KEYS.OTP_VERIFY_FAILED(testIP)).toBe( + `otp:verify:failed:${testIP}` + ); + expect(REDIS_KEYS.OTP_VERIFY_FAILED_EMAIL(testEmailHash)).toBe( + `otp:verify:failed:email:${testEmailHash}` + ); + }); + + it('should generate correct MFA keys', () => { + expect(REDIS_KEYS.MFA_CHALLENGE(testIP)).toBe(`mfa:challenge:${testIP}`); + expect(REDIS_KEYS.MFA_VERIFY_FAILED(testIP)).toBe( + `mfa:verify:failed:${testIP}` + ); + }); + + it('should generate correct reauth keys', () => { + expect(REDIS_KEYS.REAUTH_SEND(testIP)).toBe(`reauth:send:${testIP}`); + expect(REDIS_KEYS.REAUTH_VERIFY_FAILED(testIP)).toBe( + `reauth:verify:failed:${testIP}` + ); + }); + + it('should generate correct password login keys', () => { + expect(REDIS_KEYS.PASSWORD_LOGIN_FAILED(testIP)).toBe( + `password:login:failed:${testIP}` + ); + }); + + it('should generate correct IP block keys', () => { + expect(REDIS_KEYS.IP_BLOCKED(testIP)).toBe(`ip:blocked:${testIP}`); + expect(REDIS_KEYS.IP_BLOCK_LEVEL(testIP)).toBe( + `ip:block:level:${testIP}` + ); + }); + }); + + describe('WINDOW_MS constants', () => { + it('should have correct time windows', () => { + expect(WINDOW_MS.ONE_MINUTE).toBe(60 * 1000); + expect(WINDOW_MS.TEN_MINUTES).toBe(10 * 60 * 1000); + expect(WINDOW_MS.ONE_HOUR).toBe(60 * 60 * 1000); + expect(WINDOW_MS.TWENTY_FOUR_HOURS).toBe(24 * 60 * 60 * 1000); + }); + + it('should have windows in proper order', () => { + expect(WINDOW_MS.ONE_MINUTE).toBeLessThan(WINDOW_MS.ONE_HOUR); + expect(WINDOW_MS.TEN_MINUTES).toBeLessThan(WINDOW_MS.ONE_HOUR); + expect(WINDOW_MS.ONE_HOUR).toBeLessThan(WINDOW_MS.TWENTY_FOUR_HOURS); + }); + }); + + describe('checkOTPSendLimit', () => { + it('blocks repeated sends to the same email across different IPs during cooldown', async () => { + const email = `cooldown-${Date.now()}@example.com`; + + const firstAttempt = await checkOTPSendLimit('198.51.100.1', email); + const secondAttempt = await checkOTPSendLimit('198.51.100.2', email); + + expect(firstAttempt.allowed).toBe(true); + expect(secondAttempt.allowed).toBe(false); + expect(secondAttempt.retryAfter).toBeGreaterThan(0); + }); + + it('caps successful sends for the same email across distributed IPs within an hour', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-12T00:00:00.000Z')); + + const email = `hourly-${Date.now()}@example.com`; + + const first = await checkOTPSendLimit('203.0.113.1', email); + vi.advanceTimersByTime( + ABUSE_THRESHOLDS.OTP_SEND_EMAIL_COOLDOWN_WINDOW_MS + 1 + ); + const second = await checkOTPSendLimit('203.0.113.2', email); + vi.advanceTimersByTime( + ABUSE_THRESHOLDS.OTP_SEND_EMAIL_COOLDOWN_WINDOW_MS + 1 + ); + const third = await checkOTPSendLimit('203.0.113.3', email); + + expect(first.allowed).toBe(true); + expect(second.allowed).toBe(true); + expect(third.allowed).toBe(false); + expect(third.retryAfter).toBeGreaterThan(0); + }); + + it('caps slow OTP send abuse from a single IP over a day', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-12T00:00:00.000Z')); + + const emailBase = `slow-ip-${Date.now()}`; + let lastAttempt = await checkOTPSendLimit( + '198.51.100.20', + `${emailBase}-0@example.com` + ); + + for ( + let attempt = 1; + attempt <= ABUSE_THRESHOLDS.OTP_SEND_PER_DAY; + attempt++ + ) { + vi.advanceTimersByTime(90 * 60 * 1000); + lastAttempt = await checkOTPSendLimit( + '198.51.100.20', + `${emailBase}-${attempt}@example.com` + ); + } + + expect(lastAttempt.allowed).toBe(false); + expect(lastAttempt.retryAfter).toBeGreaterThan(0); + }); + }); +}); + +describe('abuse-protection types', () => { + it('should have all expected abuse event types as valid strings', () => { + // These are the valid AbuseEventType values as defined in types.ts + const validTypes = [ + 'otp_send', + 'otp_verify_failed', + 'mfa_challenge', + 'mfa_verify_failed', + 'reauth_send', + 'reauth_verify_failed', + 'password_login_failed', + 'api_auth_failed', + 'api_rate_limited', + 'api_abuse', + 'manual', + ]; + // Just verify these are valid string values + validTypes.forEach((type) => { + expect(typeof type).toBe('string'); + }); + }); +}); + +describe('IP address validation', () => { + // Test IPv4 validation through extractIPFromHeaders + describe('IPv4 addresses', () => { + it('should accept valid IPv4 addresses', () => { + const validIPs = [ + '0.0.0.0', + '192.168.1.1', + '255.255.255.255', + '10.0.0.1', + '172.16.0.1', + ]; + validIPs.forEach((ip) => { + const headers = new Headers(); + headers.set('x-forwarded-for', ip); + expect(extractIPFromHeaders(headers)).toBe(ip); + }); + }); + }); + + describe('IPv6 addresses', () => { + it('should accept common IPv6 addresses', () => { + // Test the IPv6 addresses that match the current regex pattern + const supportedIPs = ['::1', '2001:db8::1', 'fe80::1']; + supportedIPs.forEach((ip) => { + const headers = new Headers(); + headers.set('x-forwarded-for', ip); + expect(extractIPFromHeaders(headers)).toBe(ip); + }); + }); + + it('should handle IPv6 formats matching the regex pattern', () => { + // The regex pattern is basic and may not match all valid IPv6 formats + // like ::ffff:192.168.1.1 (IPv4-mapped IPv6) + const headers = new Headers(); + headers.set('x-forwarded-for', '::1'); + expect(extractIPFromHeaders(headers)).toBe('::1'); + }); + }); +}); diff --git a/packages/utils/src/abuse-protection/constants.ts b/packages/utils/src/abuse-protection/constants.ts new file mode 100644 index 0000000000..5d60d9c318 --- /dev/null +++ b/packages/utils/src/abuse-protection/constants.ts @@ -0,0 +1,107 @@ +/** + * Configuration constants for OTP Abuse Protection System + */ + +/** + * Rate limiting thresholds for different operations + */ +export const ABUSE_THRESHOLDS = { + // OTP Send limits + OTP_SEND_PER_MINUTE: 3, + OTP_SEND_PER_HOUR: 10, + OTP_SEND_PER_DAY: 12, + OTP_SEND_EMAIL_COOLDOWN_WINDOW_MS: 15 * 60 * 1000, // 15 minutes + OTP_SEND_EMAIL_PER_HOUR: 2, + OTP_SEND_EMAIL_PER_DAY: 4, + + // OTP Verify limits (per IP) + OTP_VERIFY_FAILED_WINDOW_MS: 5 * 60 * 1000, // 5 minutes + OTP_VERIFY_FAILED_MAX: 5, + + // OTP Verify limits (per email - distributed attack protection) + OTP_VERIFY_FAILED_EMAIL_WINDOW_MS: 15 * 60 * 1000, // 15 minutes + OTP_VERIFY_FAILED_EMAIL_MAX: 10, + + // MFA limits + MFA_CHALLENGE_PER_MINUTE: 5, + MFA_VERIFY_FAILED_WINDOW_MS: 5 * 60 * 1000, // 5 minutes + MFA_VERIFY_FAILED_MAX: 5, + + // Reauth limits + REAUTH_SEND_PER_MINUTE: 3, + REAUTH_VERIFY_FAILED_WINDOW_MS: 5 * 60 * 1000, // 5 minutes + REAUTH_VERIFY_FAILED_MAX: 5, + + // Password login limits + PASSWORD_LOGIN_FAILED_WINDOW_MS: 5 * 60 * 1000, // 5 minutes + PASSWORD_LOGIN_FAILED_MAX: 10, + + // API auth failure limits (session-auth routes) + API_AUTH_FAILED_WINDOW_MS: 5 * 60 * 1000, // 5 minutes + API_AUTH_FAILED_MAX: 20, // 20 failed auths in 5 min → auto-block IP +} as const; + +/** + * Progressive block durations in seconds + * Level increases with repeated offenses within 24 hours + */ +export const BLOCK_DURATIONS: Record<1 | 2 | 3 | 4, number> = { + 1: 5 * 60, // Level 1: 5 minutes + 2: 15 * 60, // Level 2: 15 minutes + 3: 60 * 60, // Level 3: 1 hour + 4: 24 * 60 * 60, // Level 4: 24 hours +} as const; + +/** + * Maximum block level + */ +export const MAX_BLOCK_LEVEL = 4; + +/** + * Redis key prefixes for rate limiting + */ +export const REDIS_KEYS = { + // OTP Send attempts per IP (sliding window) + OTP_SEND: (ip: string) => `otp:send:${ip}`, + OTP_SEND_HOURLY: (ip: string) => `otp:send:hourly:${ip}`, + OTP_SEND_DAILY: (ip: string) => `otp:send:daily:${ip}`, + OTP_SEND_EMAIL_COOLDOWN: (emailHash: string) => + `otp:send:email:cooldown:${emailHash}`, + OTP_SEND_EMAIL_HOURLY: (emailHash: string) => + `otp:send:email:hourly:${emailHash}`, + OTP_SEND_EMAIL_DAILY: (emailHash: string) => + `otp:send:email:daily:${emailHash}`, + + // OTP Verify failed attempts + OTP_VERIFY_FAILED: (ip: string) => `otp:verify:failed:${ip}`, + OTP_VERIFY_FAILED_EMAIL: (emailHash: string) => + `otp:verify:failed:email:${emailHash}`, + + // MFA attempts + MFA_CHALLENGE: (ip: string) => `mfa:challenge:${ip}`, + MFA_VERIFY_FAILED: (ip: string) => `mfa:verify:failed:${ip}`, + + // Reauth attempts + REAUTH_SEND: (ip: string) => `reauth:send:${ip}`, + REAUTH_VERIFY_FAILED: (ip: string) => `reauth:verify:failed:${ip}`, + + // Password login attempts + PASSWORD_LOGIN_FAILED: (ip: string) => `password:login:failed:${ip}`, + + // API auth failure attempts (session-auth routes) + API_AUTH_FAILED: (ip: string) => `api:auth:failed:${ip}`, + + // IP block cache + IP_BLOCKED: (ip: string) => `ip:blocked:${ip}`, + IP_BLOCK_LEVEL: (ip: string) => `ip:block:level:${ip}`, +} as const; + +/** + * Window durations in milliseconds for different operations + */ +export const WINDOW_MS = { + ONE_MINUTE: 60 * 1000, + TEN_MINUTES: 10 * 60 * 1000, + ONE_HOUR: 60 * 60 * 1000, + TWENTY_FOUR_HOURS: 24 * 60 * 60 * 1000, +} as const; diff --git a/packages/utils/src/abuse-protection/edge.ts b/packages/utils/src/abuse-protection/edge.ts new file mode 100644 index 0000000000..9388ed3d00 --- /dev/null +++ b/packages/utils/src/abuse-protection/edge.ts @@ -0,0 +1,101 @@ +/** + * Edge-compatible abuse protection utilities. + * + * Unlike the main index.ts (which uses node:crypto), this module only + * relies on the @upstash/redis REST client and is safe to run in + * Vercel Edge Runtime or Next.js proxy/middleware. + */ + +import { getUpstashRestRedisClient } from '../upstash-rest'; +import { REDIS_KEYS } from './constants'; +import type { BlockInfo } from './types'; + +/** + * Lazy-loaded Redis client for Edge Runtime. + * Uses @upstash/redis REST API (no Node.js dependencies). + * Typed with a minimal interface to avoid class compatibility issues across + * different @upstash/redis resolution paths. + */ +interface EdgeRedisClient { + get: (key: string) => Promise; +} + +let edgeRedisClient: EdgeRedisClient | null = null; +let edgeRedisInitialized = false; + +async function getEdgeRedisClient() { + if (edgeRedisInitialized) return edgeRedisClient; + + try { + edgeRedisClient = await getUpstashRestRedisClient(); + edgeRedisInitialized = true; + return edgeRedisClient; + } catch { + edgeRedisInitialized = true; + return null; + } +} + +/** + * Check if an IP is blocked using Redis cache only (no DB fallback). + * Designed for Edge Runtime where speed > completeness. + * The serverless layer provides full DB-backed check as backup. + */ +export async function isIPBlockedEdge( + ipAddress: string +): Promise { + try { + const redis = await getEdgeRedisClient(); + if (!redis) return null; + + const cached = await redis.get(REDIS_KEYS.IP_BLOCKED(ipAddress)); + if (!cached) return null; + + const blockInfo = typeof cached === 'string' ? JSON.parse(cached) : cached; + if (new Date(blockInfo.expiresAt) <= new Date()) return null; + + return { + id: blockInfo.id, + blockLevel: blockInfo.level, + reason: blockInfo.reason, + expiresAt: new Date(blockInfo.expiresAt), + blockedAt: new Date(blockInfo.blockedAt), + }; + } catch { + // Fail-open: if Redis is unavailable, allow request through + return null; + } +} + +/** + * Lightweight IP extraction for Edge Runtime. + * Checks standard proxy headers in priority order. + */ +export function extractIPFromRequest(headers: Headers): string { + // cf-connecting-ip (Cloudflare) + const cfIP = headers.get('cf-connecting-ip'); + if (cfIP && isValidIPEdge(cfIP)) return cfIP; + + // true-client-ip (some Cloudflare/enterprise proxy setups) + const trueClientIP = headers.get('true-client-ip'); + if (trueClientIP && isValidIPEdge(trueClientIP)) return trueClientIP; + + // x-forwarded-for (most common generic proxy header) + const forwardedFor = headers.get('x-forwarded-for'); + if (forwardedFor) { + const firstIP = forwardedFor.split(',')[0]?.trim(); + if (firstIP && isValidIPEdge(firstIP)) return firstIP; + } + + // x-real-ip (Nginx) + const realIP = headers.get('x-real-ip'); + if (realIP && isValidIPEdge(realIP)) return realIP; + + return 'unknown'; +} + +function isValidIPEdge(ip: string): boolean { + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; + const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/; + return ipv4Regex.test(ip) || ipv6Regex.test(ip); +} diff --git a/packages/utils/src/abuse-protection/index.ts b/packages/utils/src/abuse-protection/index.ts new file mode 100644 index 0000000000..9020f3f8dc --- /dev/null +++ b/packages/utils/src/abuse-protection/index.ts @@ -0,0 +1,856 @@ +/** + * OTP Abuse Protection System + * + * Provides rate limiting and IP blocking for OTP-related operations + * to prevent brute force attacks and enumeration. + */ + +import { createHash } from 'node:crypto'; +import type { Redis } from '@upstash/redis'; +import { getUpstashRestRedisClient, hasUpstashRestEnv } from '../upstash-rest'; +import { generateRandomUUID } from '../uuid-helper'; +import { + ABUSE_THRESHOLDS, + BLOCK_DURATIONS, + MAX_BLOCK_LEVEL, + REDIS_KEYS, + WINDOW_MS, +} from './constants'; +import type { AbuseCheckResult, AbuseEventType, BlockInfo } from './types'; + +// Re-export types and constants +export * from './constants'; +export * from './types'; + +// In-memory fallback store +const memoryStore = new Map(); + +// Redis client singleton (lazy initialized) +let redisClient: Redis | null = null; +let redisInitialized = false; + +/** + * Initialize Redis client from Upstash environment variables + */ +async function getRedisClient(): Promise { + if (redisInitialized) return redisClient; + + try { + if (!hasUpstashRestEnv()) { + console.warn( + '[Abuse Protection] Redis not configured - falling back to memory' + ); + redisInitialized = true; + return null; + } + + redisClient = await getUpstashRestRedisClient(); + redisInitialized = true; + return redisClient; + } catch (error) { + console.warn('[Abuse Protection] Redis unavailable:', error); + redisInitialized = true; + return null; + } +} + +/** + * Validate IP address format + */ +function isValidIP(ip: string): boolean { + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; + const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/; + return ipv4Regex.test(ip) || ipv6Regex.test(ip); +} + +/** + * Extract client IP address from request headers + * Works with Next.js headers() in Server Actions + */ +export function extractIPFromHeaders( + headers: Headers | Map | Record +): string { + const getHeader = (name: string): string | null => { + if (headers instanceof Headers) { + return headers.get(name); + } + if (headers instanceof Map) { + return headers.get(name) || null; + } + return headers[name] || null; + }; + + // Check cf-connecting-ip (Cloudflare) + const cfIP = getHeader('cf-connecting-ip'); + if (cfIP && isValidIP(cfIP)) { + return cfIP; + } + + // Check true-client-ip (some Cloudflare/enterprise proxy setups) + const trueClientIP = getHeader('true-client-ip'); + if (trueClientIP && isValidIP(trueClientIP)) { + return trueClientIP; + } + + // Check x-forwarded-for after explicit client IP headers + const forwardedFor = getHeader('x-forwarded-for'); + if (forwardedFor) { + const firstIP = forwardedFor.split(',')[0]?.trim(); + if (firstIP && isValidIP(firstIP)) { + return firstIP; + } + } + + // Check x-real-ip (Nginx) + const realIP = getHeader('x-real-ip'); + if (realIP && isValidIP(realIP)) { + return realIP; + } + + return 'unknown'; +} + +/** + * Hash email for privacy when storing in logs + */ +export function hashEmail(email: string): string { + return createHash('sha256') + .update(email.trim().toLowerCase()) + .digest('hex') + .substring(0, 16); +} + +/** + * Increment a counter in Redis or memory with automatic expiration + */ +async function incrementCounter( + key: string, + windowMs: number +): Promise<{ count: number; ttl: number }> { + const redis = await getRedisClient(); + + if (redis) { + try { + const count = await redis.incr(key); + if (count === 1) { + await redis.expire(key, Math.ceil(windowMs / 1000)); + } + const ttl = await redis.ttl(key); + return { count, ttl: ttl > 0 ? ttl : Math.ceil(windowMs / 1000) }; + } catch (error) { + console.error('[Abuse Protection] Redis error:', error); + // Fall through to memory + } + } + + // Memory fallback + const now = Date.now(); + const existing = memoryStore.get(key); + + if (!existing || now > existing.expiresAt) { + memoryStore.set(key, { count: 1, expiresAt: now + windowMs }); + return { count: 1, ttl: Math.ceil(windowMs / 1000) }; + } + + existing.count++; + return { + count: existing.count, + ttl: Math.ceil((existing.expiresAt - now) / 1000), + }; +} + +/** + * Get counter value from Redis or memory + */ +async function getCounter(key: string): Promise { + const redis = await getRedisClient(); + + if (redis) { + try { + const count = await redis.get(key); + return count || 0; + } catch { + // Fall through to memory + } + } + + const existing = memoryStore.get(key); + if (!existing || Date.now() > existing.expiresAt) { + return 0; + } + return existing.count; +} + +/** + * Delete keys from Redis or memory + */ +async function deleteKeys(...keys: string[]): Promise { + const redis = await getRedisClient(); + + if (redis) { + try { + await redis.del(...keys); + return; + } catch { + // Fall through to memory + } + } + + for (const key of keys) { + memoryStore.delete(key); + } +} + +/** + * Check if an IP is currently blocked + */ +export async function isIPBlocked( + ipAddress: string +): Promise { + try { + const redis = await getRedisClient(); + if (!redis) return null; + + const cached = await redis.get(REDIS_KEYS.IP_BLOCKED(ipAddress)); + if (!cached) return null; + + const blockInfo = typeof cached === 'string' ? JSON.parse(cached) : cached; + if (new Date(blockInfo.expiresAt) <= new Date()) return null; + + return { + id: blockInfo.id, + blockLevel: blockInfo.level, + reason: blockInfo.reason, + expiresAt: new Date(blockInfo.expiresAt), + blockedAt: new Date(blockInfo.blockedAt), + }; + } catch { + // Fail-open: if Redis is unavailable, allow request through + return null; + } +} + +/** + * Block an IP address with progressive duration + */ +export async function blockIP( + ipAddress: string, + reason: AbuseEventType +): Promise { + try { + const redis = await getRedisClient(); + + // Get current block level + let currentLevel = 0; + if (redis) { + try { + const level = await redis.get( + REDIS_KEYS.IP_BLOCK_LEVEL(ipAddress) + ); + currentLevel = level || 0; + } catch (error) { + console.error( + '[Abuse Protection] Error fetching IP block level:', + error + ); + } + } + + // Calculate new block level (max 4) + const newLevel = Math.min(currentLevel + 1, MAX_BLOCK_LEVEL) as + | 1 + | 2 + | 3 + | 4; + const blockDuration = BLOCK_DURATIONS[newLevel]; + const expiresAt = new Date(Date.now() + blockDuration * 1000); + + // Update Redis cache + if (redis) { + await Promise.all([ + redis.set( + REDIS_KEYS.IP_BLOCKED(ipAddress), + JSON.stringify({ + id: generateRandomUUID(), + level: newLevel, + reason, + expiresAt: expiresAt.toISOString(), + blockedAt: new Date().toISOString(), + }), + { ex: blockDuration } + ), + redis.set(REDIS_KEYS.IP_BLOCK_LEVEL(ipAddress), newLevel, { + ex: WINDOW_MS.TWENTY_FOUR_HOURS / 1000, + }), + ]); + } + + console.log( + `[Abuse Protection] Blocked IP ${ipAddress} at level ${newLevel} for ${blockDuration}s due to ${reason}` + ); + } catch (error) { + console.error('[Abuse Protection] Error blocking IP:', error); + } +} + +/** + * Manually unblock an IP address + */ +export async function unblockIP( + ipAddress: string, + unblockingUserId: string +): Promise { + try { + const redis = await getRedisClient(); + + // Clear Redis cache + if (redis) { + await deleteKeys( + REDIS_KEYS.IP_BLOCKED(ipAddress), + REDIS_KEYS.IP_BLOCK_LEVEL(ipAddress) + ); + } + + console.log( + `[Abuse Protection] Unblocked IP ${ipAddress} by user ${unblockingUserId}` + ); + return true; + } catch (error) { + console.error('[Abuse Protection] Error unblocking IP:', error); + return false; + } +} + +/** + * Check and track OTP send attempts + */ +export async function checkOTPSendLimit( + ipAddress: string, + email?: string +): Promise { + // First check if IP is blocked + const blockInfo = await isIPBlocked(ipAddress); + if (blockInfo) { + const retryAfter = Math.ceil( + (blockInfo.expiresAt.getTime() - Date.now()) / 1000 + ); + return { + allowed: false, + blocked: true, + reason: `IP blocked due to ${blockInfo.reason}. Block level: ${blockInfo.blockLevel}`, + retryAfter, + }; + } + + // Check per-minute limit + const minuteKey = REDIS_KEYS.OTP_SEND(ipAddress); + const { count: minuteCount, ttl: minuteTTL } = await incrementCounter( + minuteKey, + WINDOW_MS.ONE_MINUTE + ); + + if (minuteCount > ABUSE_THRESHOLDS.OTP_SEND_PER_MINUTE) { + if (minuteCount > ABUSE_THRESHOLDS.OTP_SEND_PER_MINUTE * 2) { + // Aggressive abuse - block IP + void blockIP(ipAddress, 'otp_send'); + } + + return { + allowed: false, + reason: 'Too many OTP requests. Please try again later.', + retryAfter: minuteTTL, + remainingAttempts: 0, + }; + } + + // Check hourly limit + const hourlyKey = REDIS_KEYS.OTP_SEND_HOURLY(ipAddress); + const { count: hourlyCount, ttl: hourlyTTL } = await incrementCounter( + hourlyKey, + WINDOW_MS.ONE_HOUR + ); + + if (hourlyCount > ABUSE_THRESHOLDS.OTP_SEND_PER_HOUR) { + void blockIP(ipAddress, 'otp_send'); + + return { + allowed: false, + reason: 'Hourly OTP limit reached. Please try again later.', + retryAfter: hourlyTTL, + remainingAttempts: 0, + }; + } + + const dailyKey = REDIS_KEYS.OTP_SEND_DAILY(ipAddress); + const { count: dailyCount, ttl: dailyTTL } = await incrementCounter( + dailyKey, + WINDOW_MS.TWENTY_FOUR_HOURS + ); + + if (dailyCount > ABUSE_THRESHOLDS.OTP_SEND_PER_DAY) { + void blockIP(ipAddress, 'otp_send'); + + return { + allowed: false, + reason: 'OTP limit reached. Please try again later.', + retryAfter: dailyTTL, + remainingAttempts: 0, + }; + } + + if (email) { + const emailHash = hashEmail(email); + + const cooldownKey = REDIS_KEYS.OTP_SEND_EMAIL_COOLDOWN(emailHash); + const { count: cooldownCount, ttl: cooldownTTL } = await incrementCounter( + cooldownKey, + ABUSE_THRESHOLDS.OTP_SEND_EMAIL_COOLDOWN_WINDOW_MS + ); + + if (cooldownCount > 1) { + return { + allowed: false, + reason: 'Too many OTP requests. Please try again later.', + retryAfter: cooldownTTL, + remainingAttempts: 0, + }; + } + + const hourlyEmailKey = REDIS_KEYS.OTP_SEND_EMAIL_HOURLY(emailHash); + const { count: hourlyEmailCount, ttl: hourlyEmailTTL } = + await incrementCounter(hourlyEmailKey, WINDOW_MS.ONE_HOUR); + + if (hourlyEmailCount > ABUSE_THRESHOLDS.OTP_SEND_EMAIL_PER_HOUR) { + return { + allowed: false, + reason: 'Hourly OTP limit reached. Please try again later.', + retryAfter: hourlyEmailTTL, + remainingAttempts: 0, + }; + } + + const dailyEmailKey = REDIS_KEYS.OTP_SEND_EMAIL_DAILY(emailHash); + const { count: dailyEmailCount, ttl: dailyEmailTTL } = + await incrementCounter(dailyEmailKey, WINDOW_MS.TWENTY_FOUR_HOURS); + + if (dailyEmailCount > ABUSE_THRESHOLDS.OTP_SEND_EMAIL_PER_DAY) { + return { + allowed: false, + reason: 'OTP limit reached. Please try again later.', + retryAfter: dailyEmailTTL, + remainingAttempts: 0, + }; + } + } + + return { + allowed: true, + remainingAttempts: ABUSE_THRESHOLDS.OTP_SEND_PER_MINUTE - minuteCount, + }; +} + +/** + * Check if OTP verification is allowed + */ +export async function checkOTPVerifyLimit( + ipAddress: string, + email: string +): Promise { + // First check if IP is blocked + const blockInfo = await isIPBlocked(ipAddress); + if (blockInfo) { + const retryAfter = Math.ceil( + (blockInfo.expiresAt.getTime() - Date.now()) / 1000 + ); + return { + allowed: false, + blocked: true, + reason: `IP blocked due to ${blockInfo.reason}`, + retryAfter, + }; + } + + // Get current counts (don't increment yet - increment on failure) + const ipKey = REDIS_KEYS.OTP_VERIFY_FAILED(ipAddress); + const emailKey = REDIS_KEYS.OTP_VERIFY_FAILED_EMAIL(hashEmail(email)); + + const [ipCount, emailCount] = await Promise.all([ + getCounter(ipKey), + getCounter(emailKey), + ]); + + if (ipCount >= ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_MAX) { + return { + allowed: false, + reason: 'Too many failed verification attempts from this IP', + retryAfter: Math.ceil( + ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_WINDOW_MS / 1000 + ), + remainingAttempts: 0, + }; + } + + if (emailCount >= ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_EMAIL_MAX) { + return { + allowed: false, + reason: 'Too many failed verification attempts for this email', + retryAfter: Math.ceil( + ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_EMAIL_WINDOW_MS / 1000 + ), + remainingAttempts: 0, + }; + } + + return { + allowed: true, + remainingAttempts: ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_MAX - ipCount, + }; +} + +/** + * Record a failed OTP verification attempt + */ +export async function recordOTPVerifyFailure( + ipAddress: string, + email: string +): Promise { + const ipKey = REDIS_KEYS.OTP_VERIFY_FAILED(ipAddress); + const emailKey = REDIS_KEYS.OTP_VERIFY_FAILED_EMAIL(hashEmail(email)); + + const [{ count: ipCount }] = await Promise.all([ + incrementCounter(ipKey, ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_WINDOW_MS), + incrementCounter( + emailKey, + ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_EMAIL_WINDOW_MS + ), + ]); + + // Block if threshold exceeded + if (ipCount >= ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_MAX) { + } +} + +/** + * Clear failed attempts on successful verification + */ +export async function clearOTPVerifyFailures( + ipAddress: string, + email: string +): Promise { + await deleteKeys( + REDIS_KEYS.OTP_VERIFY_FAILED(ipAddress), + REDIS_KEYS.OTP_VERIFY_FAILED_EMAIL(hashEmail(email)) + ); +} + +/** + * Check and track MFA challenge attempts + */ +export async function checkMFAChallengeLimit( + ipAddress: string +): Promise { + const blockInfo = await isIPBlocked(ipAddress); + if (blockInfo) { + return { + allowed: false, + blocked: true, + reason: `IP blocked due to ${blockInfo.reason}`, + retryAfter: Math.ceil( + (blockInfo.expiresAt.getTime() - Date.now()) / 1000 + ), + }; + } + + const key = REDIS_KEYS.MFA_CHALLENGE(ipAddress); + const { count, ttl } = await incrementCounter(key, WINDOW_MS.ONE_MINUTE); + + if (count > ABUSE_THRESHOLDS.MFA_CHALLENGE_PER_MINUTE) { + return { + allowed: false, + reason: 'Too many MFA challenge requests', + retryAfter: ttl, + remainingAttempts: 0, + }; + } + + return { allowed: true }; +} + +/** + * Check if MFA verification is allowed + */ +export async function checkMFAVerifyLimit( + ipAddress: string +): Promise { + const blockInfo = await isIPBlocked(ipAddress); + if (blockInfo) { + return { + allowed: false, + blocked: true, + reason: `IP blocked due to ${blockInfo.reason}`, + retryAfter: Math.ceil( + (blockInfo.expiresAt.getTime() - Date.now()) / 1000 + ), + }; + } + + const key = REDIS_KEYS.MFA_VERIFY_FAILED(ipAddress); + const count = await getCounter(key); + + if (count >= ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_MAX) { + return { + allowed: false, + reason: 'Too many failed MFA verification attempts', + retryAfter: Math.ceil( + ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_WINDOW_MS / 1000 + ), + remainingAttempts: 0, + }; + } + + return { + allowed: true, + remainingAttempts: ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_MAX - count, + }; +} + +/** + * Record a failed MFA verification attempt + */ +export async function recordMFAVerifyFailure(ipAddress: string): Promise { + const key = REDIS_KEYS.MFA_VERIFY_FAILED(ipAddress); + const { count } = await incrementCounter( + key, + ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_WINDOW_MS + ); + + if (count >= ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_MAX) { + void blockIP(ipAddress, 'mfa_verify_failed'); + } +} + +/** + * Clear MFA failures on success + */ +export async function clearMFAVerifyFailures(ipAddress: string): Promise { + await deleteKeys(REDIS_KEYS.MFA_VERIFY_FAILED(ipAddress)); +} + +/** + * Check reauthentication send limits + */ +export async function checkReauthSendLimit( + ipAddress: string +): Promise { + const blockInfo = await isIPBlocked(ipAddress); + if (blockInfo) { + return { + allowed: false, + blocked: true, + reason: `IP blocked due to ${blockInfo.reason}`, + retryAfter: Math.ceil( + (blockInfo.expiresAt.getTime() - Date.now()) / 1000 + ), + }; + } + + const key = REDIS_KEYS.REAUTH_SEND(ipAddress); + const { count, ttl } = await incrementCounter(key, WINDOW_MS.ONE_MINUTE); + + if (count > ABUSE_THRESHOLDS.REAUTH_SEND_PER_MINUTE) { + return { + allowed: false, + reason: 'Too many reauthentication requests', + retryAfter: ttl, + remainingAttempts: 0, + }; + } + + return { allowed: true }; +} + +/** + * Check reauthentication verify limits + */ +export async function checkReauthVerifyLimit( + ipAddress: string +): Promise { + const blockInfo = await isIPBlocked(ipAddress); + if (blockInfo) { + return { + allowed: false, + blocked: true, + reason: `IP blocked due to ${blockInfo.reason}`, + retryAfter: Math.ceil( + (blockInfo.expiresAt.getTime() - Date.now()) / 1000 + ), + }; + } + + const key = REDIS_KEYS.REAUTH_VERIFY_FAILED(ipAddress); + const count = await getCounter(key); + + if (count >= ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_MAX) { + return { + allowed: false, + reason: 'Too many failed reauthentication attempts', + retryAfter: Math.ceil( + ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_WINDOW_MS / 1000 + ), + remainingAttempts: 0, + }; + } + + return { + allowed: true, + remainingAttempts: ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_MAX - count, + }; +} + +/** + * Record failed reauthentication + */ +export async function recordReauthVerifyFailure( + ipAddress: string +): Promise { + const key = REDIS_KEYS.REAUTH_VERIFY_FAILED(ipAddress); + const { count } = await incrementCounter( + key, + ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_WINDOW_MS + ); + + if (count >= ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_MAX) { + void blockIP(ipAddress, 'reauth_verify_failed'); + } +} + +/** + * Clear reauth failures on success + */ +export async function clearReauthVerifyFailures( + ipAddress: string +): Promise { + await deleteKeys(REDIS_KEYS.REAUTH_VERIFY_FAILED(ipAddress)); +} + +/** + * Check password login limits + */ +export async function checkPasswordLoginLimit( + ipAddress: string +): Promise { + const blockInfo = await isIPBlocked(ipAddress); + if (blockInfo) { + return { + allowed: false, + blocked: true, + reason: `IP blocked due to ${blockInfo.reason}`, + retryAfter: Math.ceil( + (blockInfo.expiresAt.getTime() - Date.now()) / 1000 + ), + }; + } + + const key = REDIS_KEYS.PASSWORD_LOGIN_FAILED(ipAddress); + const count = await getCounter(key); + + if (count >= ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX) { + return { + allowed: false, + reason: 'Too many failed login attempts', + retryAfter: Math.ceil( + ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_WINDOW_MS / 1000 + ), + remainingAttempts: 0, + }; + } + + return { + allowed: true, + remainingAttempts: ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX - count, + }; +} + +/** + * Record failed password login + */ +export async function recordPasswordLoginFailure( + ipAddress: string +): Promise { + const key = REDIS_KEYS.PASSWORD_LOGIN_FAILED(ipAddress); + const { count } = await incrementCounter( + key, + ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_WINDOW_MS + ); + + if (count >= ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX) { + void blockIP(ipAddress, 'password_login_failed'); + } +} + +/** + * Clear password login failures on success + */ +export async function clearPasswordLoginFailures( + ipAddress: string +): Promise { + await deleteKeys(REDIS_KEYS.PASSWORD_LOGIN_FAILED(ipAddress)); +} + +/** + * Check if an IP should be blocked for API auth abuse + */ +export async function checkApiAuthLimit( + ipAddress: string +): Promise { + const blockInfo = await isIPBlocked(ipAddress); + if (blockInfo) { + return { + allowed: false, + blocked: true, + reason: `IP blocked due to ${blockInfo.reason}`, + retryAfter: Math.ceil( + (blockInfo.expiresAt.getTime() - Date.now()) / 1000 + ), + }; + } + + const key = REDIS_KEYS.API_AUTH_FAILED(ipAddress); + const count = await getCounter(key); + + if (count >= ABUSE_THRESHOLDS.API_AUTH_FAILED_MAX) { + return { + allowed: false, + reason: 'Too many failed API authentication attempts', + retryAfter: Math.ceil(ABUSE_THRESHOLDS.API_AUTH_FAILED_WINDOW_MS / 1000), + remainingAttempts: 0, + }; + } + + return { + allowed: true, + remainingAttempts: ABUSE_THRESHOLDS.API_AUTH_FAILED_MAX - count, + }; +} + +/** + * Record a failed API auth attempt. Auto-blocks IP if threshold exceeded. + */ +export async function recordApiAuthFailure(ipAddress: string): Promise { + const key = REDIS_KEYS.API_AUTH_FAILED(ipAddress); + const { count } = await incrementCounter( + key, + ABUSE_THRESHOLDS.API_AUTH_FAILED_WINDOW_MS + ); + + if (count >= ABUSE_THRESHOLDS.API_AUTH_FAILED_MAX) { + void blockIP(ipAddress, 'api_auth_failed'); + } +} + +/** + * Clear API auth failures (e.g. on successful auth from that IP) + */ +export async function clearApiAuthFailures(ipAddress: string): Promise { + await deleteKeys(REDIS_KEYS.API_AUTH_FAILED(ipAddress)); +} diff --git a/packages/utils/src/abuse-protection/types.ts b/packages/utils/src/abuse-protection/types.ts new file mode 100644 index 0000000000..f9307beb30 --- /dev/null +++ b/packages/utils/src/abuse-protection/types.ts @@ -0,0 +1,76 @@ +/** + * Types for OTP Abuse Protection System + */ + +export type AbuseEventType = + | 'otp_send' + | 'otp_verify_failed' + | 'mfa_challenge' + | 'mfa_verify_failed' + | 'reauth_send' + | 'reauth_verify_failed' + | 'password_login_failed' + | 'api_auth_failed' + | 'api_rate_limited' + | 'api_abuse' + | 'manual'; + +export type IPBlockStatus = 'active' | 'expired' | 'manually_unblocked'; + +export interface AbuseCheckResult { + allowed: boolean; + blocked?: boolean; + reason?: string; + retryAfter?: number; // seconds until retry allowed + remainingAttempts?: number; +} + +export interface BlockInfo { + id: string; + blockLevel: number; + reason: AbuseEventType; + expiresAt: Date; + blockedAt: Date; +} + +export interface RateLimitConfig { + windowMs: number; + maxAttempts: number; +} + +export interface LogAbuseEventOptions { + email?: string; + userAgent?: string; + endpoint?: string; + success?: boolean; + metadata?: Record; +} + +export interface BlockedIP { + id: string; + ip_address: string; + reason: AbuseEventType; + block_level: number; + status: IPBlockStatus; + blocked_at: string; + expires_at: string; + unblocked_at?: string; + unblocked_by?: string; + unblock_reason?: string; + metadata: Record; + created_at: string; + updated_at: string; +} + +export interface AbuseEvent { + id: string; + ip_address: string; + event_type: AbuseEventType; + email?: string; + email_hash?: string; + user_agent?: string; + endpoint?: string; + success: boolean; + metadata: Record; + created_at: string; +} diff --git a/packages/utils/src/api-proxy-guard.ts b/packages/utils/src/api-proxy-guard.ts new file mode 100644 index 0000000000..9fe072a4ae --- /dev/null +++ b/packages/utils/src/api-proxy-guard.ts @@ -0,0 +1,334 @@ +import { Ratelimit } from '@upstash/ratelimit'; +import type { Redis } from '@upstash/redis'; +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { extractIPFromRequest, isIPBlockedEdge } from './abuse-protection/edge'; +import { getUpstashRestRedisClient } from './upstash-rest'; + +const isDev = process.env.NODE_ENV !== 'production'; +const MAX_PAYLOAD_SIZE = 200 * 1024; // 200KB + +export type RateLimitWindow = 'minute' | 'hour' | 'day'; + +export type RateLimitConfig = { + duration: '1 m' | '1 h' | '1 d'; + limit: number; + window: RateLimitWindow; +}; + +export type RateLimitProfile = { + get: RateLimitConfig[]; + mutate: RateLimitConfig[]; +}; + +export type ProxyRoutePolicy = { + key: string; + matches: (req: NextRequest) => boolean; + rateLimits: RateLimitProfile; +}; + +export type TrustedProxyBypassRule = { + matches: (pathname: string, headers: Headers) => boolean; +}; + +export type GuardOptions = { + prefixBase: string; + routePolicies?: ProxyRoutePolicy[]; + trustedBypassRules?: TrustedProxyBypassRule[]; +}; + +type RateLimitBucket = { + limiter: Ratelimit | null; + window: RateLimitWindow; +}; + +type Limiters = { + get: RateLimitBucket[]; + mutate: RateLimitBucket[]; +}; + +const limiterCache = new Map(); + +const NO_READ_RATE_LIMITS: RateLimitConfig[] = []; + +const MEET_TOGETHER_MUTATE_RATE_LIMITS: RateLimitConfig[] = [ + { window: 'minute', limit: 5, duration: '1 m' }, + { window: 'hour', limit: 50, duration: '1 h' }, + { window: 'day', limit: 200, duration: '1 d' }, +]; + +const STUDENTS_MUTATE_RATE_LIMITS: RateLimitConfig[] = [ + { window: 'minute', limit: 5, duration: '1 m' }, + { window: 'hour', limit: 50, duration: '1 h' }, + { window: 'day', limit: 200, duration: '1 d' }, +]; + +const USERS_ME_MUTATE_RATE_LIMITS: RateLimitConfig[] = [ + { window: 'minute', limit: 10, duration: '1 m' }, + { window: 'hour', limit: 90, duration: '1 h' }, + { window: 'day', limit: 240, duration: '1 d' }, +]; + +const DEFAULT_MUTATE_RATE_LIMITS: RateLimitConfig[] = [ + { window: 'minute', limit: 12, duration: '1 m' }, + { window: 'hour', limit: 120, duration: '1 h' }, + { window: 'day', limit: 400, duration: '1 d' }, +]; + +const DEFAULT_ROUTE_POLICIES: ProxyRoutePolicy[] = [ + { + key: 'meet-together', + matches: (req) => + req.nextUrl.pathname === '/api/meet-together' || + req.nextUrl.pathname.startsWith('/api/meet-together'), + rateLimits: { + get: NO_READ_RATE_LIMITS, + mutate: MEET_TOGETHER_MUTATE_RATE_LIMITS, + }, + }, + { + key: 'students', + matches: (req) => + req.nextUrl.pathname === '/api/students' || + req.nextUrl.pathname.startsWith('/api/students'), + rateLimits: { + get: NO_READ_RATE_LIMITS, + mutate: STUDENTS_MUTATE_RATE_LIMITS, + }, + }, + { + key: 'users-me', + matches: (req) => + req.nextUrl.pathname === '/api/v1/users/me' || + req.nextUrl.pathname.startsWith('/api/v1/users/me'), + rateLimits: { + get: NO_READ_RATE_LIMITS, + mutate: USERS_ME_MUTATE_RATE_LIMITS, + }, + }, + { + key: 'default', + matches: () => true, + rateLimits: { + get: NO_READ_RATE_LIMITS, + mutate: DEFAULT_MUTATE_RATE_LIMITS, + }, + }, +]; + +function hasBearerToken(headers: Headers, secrets: Array) { + const authHeader = headers.get('authorization'); + if (!authHeader) { + return false; + } + + return secrets.some( + (secret) => !!secret && authHeader === `Bearer ${secret}` + ); +} + +function hasHeaderToken( + headers: Headers, + headerName: string, + secrets: Array +) { + const headerValue = headers.get(headerName); + if (!headerValue) { + return false; + } + + return secrets.some((secret) => !!secret && headerValue === secret); +} + +const DEFAULT_TRUSTED_BYPASS_RULES: TrustedProxyBypassRule[] = [ + { + matches: (pathname, headers) => + (pathname === '/api/cron' || pathname.startsWith('/api/cron/')) && + (hasBearerToken(headers, [ + process.env.CRON_SECRET, + process.env.VERCEL_CRON_SECRET, + process.env.SUPABASE_SERVICE_ROLE_KEY, + ]) || + hasHeaderToken(headers, 'x-cron-secret', [ + process.env.CRON_SECRET, + process.env.VERCEL_CRON_SECRET, + ]) || + hasHeaderToken(headers, 'x-vercel-cron-secret', [ + process.env.CRON_SECRET, + process.env.VERCEL_CRON_SECRET, + ])), + }, + { + matches: (pathname, headers) => + (pathname === '/api/v1/webhooks' || + pathname.startsWith('/api/v1/webhooks/')) && + !!process.env.SUPABASE_WEBHOOK_SECRET && + headers.get('x-webhook-secret') === process.env.SUPABASE_WEBHOOK_SECRET, + }, +]; + +function createRateLimitBuckets( + redis: Redis, + prefixBase: string, + kind: 'get' | 'mutate', + configs: RateLimitConfig[] +): RateLimitBucket[] { + return configs.map((config) => ({ + window: config.window, + limiter: new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(config.limit, config.duration), + prefix: `${prefixBase}:${kind}:${config.window}`, + analytics: false, + }), + })); +} + +async function getRateLimiters( + prefixBase: string, + profile: RateLimitProfile +): Promise { + const cacheKey = `${prefixBase}:${JSON.stringify(profile)}`; + const cached = limiterCache.get(cacheKey); + if (cached) { + return cached; + } + + const redis = await getUpstashRestRedisClient(); + if (!redis) { + const disabled = { get: [], mutate: [] }; + limiterCache.set(cacheKey, disabled); + return disabled; + } + + const limiters = { + get: createRateLimitBuckets(redis, prefixBase, 'get', profile.get), + mutate: createRateLimitBuckets(redis, prefixBase, 'mutate', profile.mutate), + }; + + limiterCache.set(cacheKey, limiters); + return limiters; +} + +function getRoutePolicy( + req: NextRequest, + routePolicies: ProxyRoutePolicy[] +): ProxyRoutePolicy { + return ( + routePolicies.find((routePolicy) => routePolicy.matches(req)) ?? + DEFAULT_ROUTE_POLICIES[DEFAULT_ROUTE_POLICIES.length - 1]! + ); +} + +function buildRateLimitResponse( + status: 429, + retryAfter: number, + headers?: Record +) { + return NextResponse.json( + { error: 'Too Many Requests', message: 'Rate limit exceeded' }, + { + status, + headers: { + 'Retry-After': `${retryAfter}`, + ...headers, + }, + } + ); +} + +export function isTrustedProxyBypassRequest( + pathname: string, + headers: Headers, + trustedBypassRules: TrustedProxyBypassRule[] = DEFAULT_TRUSTED_BYPASS_RULES +): boolean { + return trustedBypassRules.some((rule) => rule.matches(pathname, headers)); +} + +export function clearApiProxyGuardLimiterCache() { + limiterCache.clear(); +} + +export async function guardApiProxyRequest( + req: NextRequest, + options: GuardOptions +): Promise { + const contentLength = req.headers.get('content-length'); + if (contentLength) { + const size = Number.parseInt(contentLength, 10); + if (!Number.isNaN(size) && size > MAX_PAYLOAD_SIZE) { + return NextResponse.json( + { error: 'Payload Too Large', message: 'Request body exceeds limit' }, + { status: 413 } + ); + } + } + + const routePolicies = options.routePolicies ?? DEFAULT_ROUTE_POLICIES; + const trustedBypassRules = [ + ...DEFAULT_TRUSTED_BYPASS_RULES, + ...(options.trustedBypassRules ?? []), + ]; + + if ( + !isDev && + !isTrustedProxyBypassRequest( + req.nextUrl.pathname, + req.headers, + trustedBypassRules + ) + ) { + const ip = extractIPFromRequest(req.headers); + + if (ip !== 'unknown') { + const blockInfo = await isIPBlockedEdge(ip); + if (blockInfo) { + const retryAfter = Math.max( + 1, + Math.ceil((blockInfo.expiresAt.getTime() - Date.now()) / 1000) + ); + + return buildRateLimitResponse(429, retryAfter); + } + + const routePolicy = getRoutePolicy(req, routePolicies); + const isRead = req.method === 'GET' || req.method === 'HEAD'; + const limiters = await getRateLimiters( + `${options.prefixBase}:${routePolicy.key}`, + routePolicy.rateLimits + ); + const activeLimiters = isRead ? limiters.get : limiters.mutate; + + for (const { limiter, window } of activeLimiters) { + if (!limiter) { + continue; + } + + const { success, limit, remaining, reset } = await limiter.limit(ip); + + const consumed = limit - remaining; + const kind = isRead ? 'read' : 'mutate'; + console.log( + `[ProxyGuard] ${routePolicy.key}:${kind}:${window} ${consumed}/${limit} | IP: ${ip} | path: ${req.nextUrl.pathname}` + ); + + if (!success) { + const retryAfter = Math.max( + 1, + Math.ceil((reset - Date.now()) / 1000) + ); + + return buildRateLimitResponse(429, retryAfter, { + 'X-RateLimit-Limit': `${limit}`, + 'X-RateLimit-Remaining': `${remaining}`, + 'X-RateLimit-Reset': `${Math.ceil(reset / 1000)}`, + 'X-RateLimit-Window': window, + 'X-RateLimit-Policy': routePolicy.key, + }); + } + } + } + } + + return null; +} diff --git a/packages/utils/src/upstash-rest.ts b/packages/utils/src/upstash-rest.ts index 90899f166e..bb294c07f5 100644 --- a/packages/utils/src/upstash-rest.ts +++ b/packages/utils/src/upstash-rest.ts @@ -1,13 +1,4 @@ -import type { Redis } from '@upstash/redis'; - -export type UpstashRestRedisClient = Pick< - Redis, - 'del' | 'expire' | 'get' | 'incr' | 'set' | 'ttl' ->; -export type UpstashRatelimitRedisClient = Pick< - Redis, - 'eval' | 'evalsha' | 'get' | 'set' ->; +import { Redis } from '@upstash/redis'; export function hasUpstashRestEnv(): boolean { return Boolean( @@ -16,40 +7,10 @@ export function hasUpstashRestEnv(): boolean { ); } -export async function getUpstashRestRedisClient(): Promise { - if (!hasUpstashRestEnv()) { - return null; - } - - const { Redis } = await import('@upstash/redis'); - const client = Redis.fromEnv(); - - const restClient: UpstashRestRedisClient = { - del: (...keys) => client.del(...keys), - expire: (key, seconds) => client.expire(key, seconds), - get: (key: string) => client.get(key), - incr: (key) => client.incr(key), - set: (key, value, options) => client.set(key, value, options), - ttl: (key) => client.ttl(key), - }; - - return restClient; -} - -export async function getUpstashRatelimitRedisClient(): Promise { - if (!hasUpstashRestEnv()) { - return null; - } +export async function getUpstashRestRedisClient(): Promise { + if (!hasUpstashRestEnv()) return null; - const { Redis } = await import('@upstash/redis'); const client = Redis.fromEnv(); - const ratelimitClient: UpstashRatelimitRedisClient = { - eval: (...args) => client.eval(...args), - evalsha: (...args) => client.evalsha(...args), - get: (key: string) => client.get(key), - set: (key, value, options) => client.set(key, value, options), - }; - - return ratelimitClient; + return client; }