diff --git a/.gitignore b/.gitignore index 93f60939..98efbda9 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,9 @@ coverage/ verify-*.js VERIFICATION_GUIDE.md +# Web dashboard +web/.next/ +web/.env.local +web/.env.*.local +web/tsconfig.tsbuildinfo + diff --git a/README.md b/README.md index 04c6cf87..774a97ac 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ AI-powered Discord bot for the [Volvox](https://volvox.dev) developer community. - **⚙️ Config Management** — All settings stored in PostgreSQL with live `/config` slash command for runtime changes. - **📊 Health Monitoring** — Built-in health checks and `/status` command for uptime, memory, and latency stats. - **🎤 Voice Activity Tracking** — Tracks voice channel activity for community insights. +- **🌐 Web Dashboard** — Next.js-based admin dashboard with Discord OAuth2 login, server selector, and guild management UI. ## 🏗️ Architecture @@ -108,6 +109,18 @@ pnpm dev Legacy OpenClaw aliases are also supported for backwards compatibility: `OPENCLAW_URL`, `OPENCLAW_TOKEN`. +### Web Dashboard + +| Variable | Required | Description | +|----------|----------|-------------| +| `NEXTAUTH_URL` | ✅ | Canonical URL of the dashboard (e.g. `http://localhost:3000`) | +| `NEXTAUTH_SECRET` | ✅ | Random secret for NextAuth.js JWT encryption (min 32 chars). Generate with `openssl rand -base64 48` | +| `DISCORD_CLIENT_ID` | ✅ | Discord OAuth2 application client ID | +| `DISCORD_CLIENT_SECRET` | ✅ | Discord OAuth2 application client secret | +| `NEXT_PUBLIC_DISCORD_CLIENT_ID` | ❌ | Public client ID for bot invite links in the UI | +| `BOT_API_URL` | ❌ | URL of the bot's REST API for mutual guild filtering | +| `BOT_API_SECRET` | ❌ | Shared secret for authenticating requests to the bot API | + ## ⚙️ Configuration All configuration lives in `config.json` and can be updated at runtime via the `/config` slash command. When `DATABASE_URL` is set, config is persisted to PostgreSQL. @@ -230,6 +243,44 @@ All moderation commands require the admin role (configured via `permissions.admi | `/modlog view` | View current log routing config | | `/modlog disable` | Disable all mod logging | +## 🌐 Web Dashboard + +The `web/` directory contains a Next.js admin dashboard for managing Bill Bot through a browser. + +### Features + +- **Discord OAuth2 Login** — Sign in with your Discord account via NextAuth.js +- **Server Selector** — Choose from mutual guilds (servers where both you and the bot are present) +- **Token Refresh** — Automatic Discord token refresh with graceful error handling +- **Responsive UI** — Mobile-friendly layout with sidebar navigation and dark mode support + +### Setup + +```bash +cd web +cp .env.example .env.local # Fill in Discord OAuth2 credentials +pnpm install --legacy-peer-deps +pnpm dev # Starts on http://localhost:3000 +``` + +> **Note:** `--legacy-peer-deps` is required due to NextAuth v4 + Next.js 16 peer dependency constraints. + +### Discord OAuth2 Configuration + +1. Go to your [Discord application](https://discord.com/developers/applications) → **OAuth2** +2. Add a redirect URL: `http://localhost:3000/api/auth/callback/discord` (adjust for production) +3. Copy the **Client ID** and **Client Secret** into your `.env.local` + +### Scripts + +| Command | Description | +|---------|-------------| +| `pnpm dev` | Start development server with hot reload | +| `pnpm build` | Production build | +| `pnpm start` | Start production server | +| `pnpm test` | Run tests with Vitest | +| `pnpm typecheck` | Type-check with TypeScript compiler | + ## 🛠️ Development ### Scripts diff --git a/package.json b/package.json index 83ca08d9..bd8e14ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-bot", - "packageManager": "pnpm@10.28.2", + "packageManager": "pnpm@10.29.3", "version": "1.0.0", "description": "Volvox Discord bot - AI chat, welcome messages, and moderation", "main": "src/index.js", @@ -18,7 +18,7 @@ }, "dependencies": { "discord.js": "^14.25.1", - "dotenv": "^17.2.4", + "dotenv": "^17.3.1", "mem0ai": "^2.2.2", "pg": "^8.18.0", "winston": "^3.19.0", @@ -33,7 +33,7 @@ "node": ">=18.0.0" }, "devDependencies": { - "@biomejs/biome": "^2.3.14", + "@biomejs/biome": "^2.4.0", "@vitest/coverage-v8": "^4.0.18", "vitest": "^4.0.18" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5cd80304..c097286a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^14.25.1 version: 14.25.1 dotenv: - specifier: ^17.2.4 - version: 17.2.4 + specifier: ^17.3.1 + version: 17.3.1 mem0ai: specifier: ^2.2.2 version: 2.2.2(@anthropic-ai/sdk@0.40.1(encoding@0.1.13))(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260214.0)(@google/genai@1.41.0)(@langchain/core@0.3.80(openai@4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.14.0)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.95.3)(@types/jest@29.5.14)(@types/pg@8.11.0)(@types/sqlite3@3.1.11)(cloudflare@4.5.0(encoding@0.1.13))(encoding@0.1.13)(groq-sdk@0.3.0(encoding@0.1.13))(neo4j-driver@5.28.3)(ollama@0.5.18)(pg@8.18.0)(redis@4.7.1)(sqlite3@5.1.7)(ws@8.19.0) @@ -31,20 +31,118 @@ importers: version: 5.0.0(winston@3.19.0) devDependencies: '@biomejs/biome': - specifier: ^2.3.14 - version: 2.3.14 + specifier: ^2.4.0 + version: 2.4.0 '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@25.2.0)) + version: 4.0.18(vitest@4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)) vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@25.2.0) + version: 4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2) + + web: + dependencies: + '@radix-ui/react-avatar': + specifier: ^1.1.11 + version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-separator': + specifier: ^1.1.8 + version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': + specifier: ^1.2.4 + version: 1.2.4(@types/react@19.2.14)(react@19.2.4) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + lucide-react: + specifier: ^0.525.0 + version: 0.525.0(react@19.2.4) + next: + specifier: ^16.1.6 + version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-auth: + specifier: ^4.24.13 + version: 4.24.13(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + server-only: + specifier: ^0.0.1 + version: 0.0.1 + tailwind-merge: + specifier: ^3.4.1 + version: 3.4.1 + devDependencies: + '@tailwindcss/postcss': + specifier: ^4.1.18 + version: 4.1.18 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/node': + specifier: ^22.19.11 + version: 22.19.11 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^4.7.0 + version: 4.7.0(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)) + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18(@types/node@22.19.11)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)) + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + postcss: + specifier: ^8.5.6 + version: 8.5.6 + tailwindcss: + specifier: ^4.1.18 + version: 4.1.18 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@22.19.11)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2) packages: + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + '@anthropic-ai/sdk@0.40.1': resolution: {integrity: sha512-DJMWm8lTEM9Lk/MSFL+V+ugF7jKOn0M2Ujvb5fN8r2nY14aHbGPZ1k6sgjL+tpJ3VuOGJNG+4R83jEpOuYPv8w==} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@azure/abort-controller@2.1.2': resolution: {integrity: sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==} engines: {node: '>=18.0.0'} @@ -108,6 +206,40 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -116,11 +248,43 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + '@babel/parser@7.29.0': resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} engines: {node: '>=6.0.0'} hasBin: true + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} @@ -129,59 +293,59 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@biomejs/biome@2.3.14': - resolution: {integrity: sha512-QMT6QviX0WqXJCaiqVMiBUCr5WRQ1iFSjvOLoTk6auKukJMvnMzWucXpwZB0e8F00/1/BsS9DzcKgWH+CLqVuA==} + '@biomejs/biome@2.4.0': + resolution: {integrity: sha512-iluT61cORUDIC5i/y42ljyQraCemmmcgbMLLCnYO+yh+2hjTmcMFcwY8G0zTzWCsPb3t3AyKc+0t/VuhPZULUg==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.3.14': - resolution: {integrity: sha512-UJGPpvWJMkLxSRtpCAKfKh41Q4JJXisvxZL8ChN1eNW3m/WlPFJ6EFDCE7YfUb4XS8ZFi3C1dFpxUJ0Ety5n+A==} + '@biomejs/cli-darwin-arm64@2.4.0': + resolution: {integrity: sha512-L+YpOtPSuU0etomfvFTPWRsa7+8ejaJL3yaROEoT/96HDJbR6OsvZQk0C8JUYou+XFdP+JcGxqZknkp4n934RA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.3.14': - resolution: {integrity: sha512-PNkLNQG6RLo8lG7QoWe/hhnMxJIt1tEimoXpGQjwS/dkdNiKBLPv4RpeQl8o3s1OKI3ZOR5XPiYtmbGGHAOnLA==} + '@biomejs/cli-darwin-x64@2.4.0': + resolution: {integrity: sha512-Aq+S7ffpb5ynTyLgtnEjG+W6xuTd2F7FdC7J6ShpvRhZwJhjzwITGF9vrqoOnw0sv1XWkt2Q1Rpg+hleg/Xg7Q==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.3.14': - resolution: {integrity: sha512-LInRbXhYujtL3sH2TMCH/UBwJZsoGwfQjBrMfl84CD4hL/41C/EU5mldqf1yoFpsI0iPWuU83U+nB2TUUypWeg==} + '@biomejs/cli-linux-arm64-musl@2.4.0': + resolution: {integrity: sha512-1rhDUq8sf7xX3tg7vbnU3WVfanKCKi40OXc4VleBMzRStmQHdeBY46aFP6VdwEomcVjyNiu+Zcr3LZtAdrZrjQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.3.14': - resolution: {integrity: sha512-KT67FKfzIw6DNnUNdYlBg+eU24Go3n75GWK6NwU4+yJmDYFe9i/MjiI+U/iEzKvo0g7G7MZqoyrhIYuND2w8QQ==} + '@biomejs/cli-linux-arm64@2.4.0': + resolution: {integrity: sha512-u2p54IhvNAWB+h7+rxCZe3reNfQYFK+ppDw+q0yegrGclFYnDPZAntv/PqgUacpC3uxTeuWFgWW7RFe3lHuxOA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.3.14': - resolution: {integrity: sha512-KQU7EkbBBuHPW3/rAcoiVmhlPtDSGOGRPv9js7qJVpYTzjQmVR+C9Rfcz+ti8YCH+zT1J52tuBybtP4IodjxZQ==} + '@biomejs/cli-linux-x64-musl@2.4.0': + resolution: {integrity: sha512-Omo0xhl63z47X+CrE5viEWKJhejJyndl577VoXg763U/aoATrK3r5+8DPh02GokWPeODX1Hek00OtjjooGan9w==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.3.14': - resolution: {integrity: sha512-ZsZzQsl9U+wxFrGGS4f6UxREUlgHwmEfu1IrXlgNFrNnd5Th6lIJr8KmSzu/+meSa9f4rzFrbEW9LBBA6ScoMA==} + '@biomejs/cli-linux-x64@2.4.0': + resolution: {integrity: sha512-WVFOhsnzhrbMGOSIcB9yFdRV2oG2KkRRhIZiunI9gJqSU3ax9ErdnTxRfJUxZUI9NbzVxC60OCXNcu+mXfF/Tw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.3.14': - resolution: {integrity: sha512-+IKYkj/pUBbnRf1G1+RlyA3LWiDgra1xpS7H2g4BuOzzRbRB+hmlw0yFsLprHhbbt7jUzbzAbAjK/Pn0FDnh1A==} + '@biomejs/cli-win32-arm64@2.4.0': + resolution: {integrity: sha512-aqRwW0LJLV1v1NzyLvLWQhdLmDSAV1vUh+OBdYJaa8f28XBn5BZavo+WTfqgEzALZxlNfBmu6NGO6Al3MbCULw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.3.14': - resolution: {integrity: sha512-oizCjdyQ3WJEswpb3Chdngeat56rIdSYK12JI3iI11Mt5T5EXcZ7WLuowzEaFPNJ3zmOQFliMN8QY1Pi+qsfdQ==} + '@biomejs/cli-win32-x64@2.4.0': + resolution: {integrity: sha512-g47s+V+OqsGxbSZN3lpav6WYOk0PIc3aCBAq+p6dwSynL3K5MA6Cg6nkzDOlu28GEHwbakW+BllzHCJCxnfK5Q==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -196,6 +360,34 @@ packages: resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} @@ -227,6 +419,9 @@ packages: resolution: {integrity: sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==} engines: {node: '>=16.11.0'} + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -383,6 +578,21 @@ packages: cpu: [x64] os: [win32] + '@floating-ui/core@1.7.4': + resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} + + '@floating-ui/dom@1.7.5': + resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} + + '@floating-ui/react-dom@2.1.7': + resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} @@ -395,6 +605,159 @@ packages: '@modelcontextprotocol/sdk': optional: true + '@img/colour@1.0.0': + resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -411,6 +774,12 @@ packages: resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -428,6 +797,61 @@ packages: '@mistralai/mistralai@1.14.0': resolution: {integrity: sha512-6zaj2f2LCd37cRpBvCgctkDbXtYBlAC85p+u4uU/726zjtsI+sdVH34qRzkm9iE3tRb8BoaiI0/P7TD+uMvLLQ==} + '@next/env@16.1.6': + resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} + + '@next/swc-darwin-arm64@16.1.6': + resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.1.6': + resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.1.6': + resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@16.1.6': + resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@16.1.6': + resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@16.1.6': + resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@16.1.6': + resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.1.6': + resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@npmcli/fs@1.1.1': resolution: {integrity: sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==} @@ -436,6 +860,9 @@ packages: engines: {node: '>=10'} deprecated: This functionality has been moved to @npmcli/fs + '@panva/hkdf@1.2.1': + resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -480,6 +907,351 @@ packages: resolution: {integrity: sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA==} engines: {node: '>=18.0.0', pnpm: '>=8'} + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.1.11': + resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.3': + resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.8': + resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@redis/bloom@1.2.0': resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} peerDependencies: @@ -509,6 +1281,9 @@ packages: peerDependencies: '@redis/client': ^1.0.0 + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rollup/rollup-android-arm-eabi@4.57.1': resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] @@ -695,10 +1470,149 @@ packages: resolution: {integrity: sha512-Fukw1cUTQ6xdLiHDJhKKPu6svEPaCEDvThqCne3OaQyZvuq2qjhJAd91kJu3PXLG18aooCgYBaB6qQz35hhABg==} engines: {node: '>=20.0.0'} + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tailwindcss/node@4.1.18': + resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} + + '@tailwindcss/oxide-android-arm64@4.1.18': + resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.18': + resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.18': + resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.1.18': + resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.1.18': + resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.1.18': + resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.18': + resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} + engines: {node: '>= 10'} + + '@tailwindcss/postcss@4.1.18': + resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tootallnate/once@1.1.2': resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} engines: {node: '>= 6'} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -726,8 +1640,11 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@25.2.0': - resolution: {integrity: sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==} + '@types/node@22.19.11': + resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==} + + '@types/node@25.2.3': + resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} '@types/pg@8.11.0': resolution: {integrity: sha512-sDAlRiBNthGjNFfvt0k6mtotoVYVQ63pA8R4EMWka7crawSR60waVYR0HAgmPRs/e2YaeJTD/43OoZ3PFw80pw==} @@ -735,6 +1652,14 @@ packages: '@types/phoenix@1.6.7': resolution: {integrity: sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} @@ -763,6 +1688,12 @@ packages: resolution: {integrity: sha512-91fp6CAAJSRtH5ja95T1FHSKa8aPW9/Zw6cta81jlZTUw/+Vq8jM/AfF/14h2b71wwR84JUTW/3Y8QPhDAawFA==} engines: {node: '>=20.0.0'} + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/coverage-v8@4.0.18': resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} peerDependencies: @@ -856,6 +1787,17 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} deprecated: This package is no longer supported. + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -881,6 +1823,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.9.19: + resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + hasBin: true + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -900,6 +1846,11 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -925,6 +1876,9 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} + caniuse-lite@1.0.30001770: + resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -947,13 +1901,23 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cloudflare@4.5.0: resolution: {integrity: sha512-fPcbPKx4zF45jBvQ0z7PCdgejVAPBBCZxwqk1k7krQNfpM07Cfj97/Q6wBzvYqlWXx/zt1S9+m8vnfCe06umbQ==} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + cluster-key-slot@1.1.2: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} @@ -998,6 +1962,13 @@ packages: console-table-printer@2.15.0: resolution: {integrity: sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1005,10 +1976,24 @@ packages: crypt@0.0.2: resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1022,6 +2007,9 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -1049,10 +2037,17 @@ packages: delegates@1.0.0: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + diff-sequences@29.6.3: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1060,15 +2055,21 @@ packages: digest-fetch@1.3.0: resolution: {integrity: sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==} - discord-api-types@0.38.38: - resolution: {integrity: sha512-7qcM5IeZrfb+LXW07HvoI5L+j4PQeMZXEkSm1htHAHh4Y9JSMXBWjy/r7zmUCOj4F7zNjMcm7IMWr131MT2h0Q==} + discord-api-types@0.38.39: + resolution: {integrity: sha512-XRdDQvZvID1XvcFftjSmd4dcmMi/RL/jSy5sduBDAvCGFcNFHThdIQXCEBDZFe52lCNEzuIL0QJoKYAmRmxLUA==} discord.js@14.25.1: resolution: {integrity: sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==} engines: {node: '>=18'} - dotenv@17.2.4: - resolution: {integrity: sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} dunder-proto@1.0.1: @@ -1081,6 +2082,9 @@ packages: ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + electron-to-chromium@1.5.286: + resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1096,6 +2100,14 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.19.0: + resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} + engines: {node: '>=10.13.0'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -1127,6 +2139,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-string-regexp@2.0.0: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} @@ -1255,10 +2271,18 @@ packages: resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} engines: {node: '>= 4'} + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -1316,6 +2340,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -1405,6 +2433,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -1451,6 +2482,13 @@ packages: resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + js-tiktoken@1.0.21: resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} @@ -1460,9 +2498,28 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} @@ -1493,6 +2550,80 @@ packages: openai: optional: true + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -1530,10 +2661,22 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lucide-react@0.525.0: + resolution: {integrity: sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-bytes.js@1.13.0: resolution: {integrity: sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==} @@ -1598,6 +2741,10 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -1683,6 +2830,41 @@ packages: neo4j-driver@5.28.3: resolution: {integrity: sha512-k7c0wEh3HoONv1v5AyLp9/BDAbYHJhz2TZvzWstSEU3g3suQcXmKEaYBfrK2UMzxcy3bCT0DrnfRbzsOW5G/Ag==} + next-auth@4.24.13: + resolution: {integrity: sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==} + peerDependencies: + '@auth/core': 0.34.3 + next: ^12.2.5 || ^13 || ^14 || ^15 || ^16 + nodemailer: ^7.0.7 + react: ^17.0.2 || ^18 || ^19 + react-dom: ^17.0.2 || ^18 || ^19 + peerDependenciesMeta: + '@auth/core': + optional: true + nodemailer: + optional: true + + next@16.1.6: + resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} + engines: {node: '>=20.9.0'} + hasBin: true + 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 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + node-abi@3.87.0: resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} engines: {node: '>=10'} @@ -1713,6 +2895,9 @@ packages: engines: {node: '>= 10.12.0'} hasBin: true + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + nopt@5.0.0: resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} engines: {node: '>=6'} @@ -1723,6 +2908,16 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} deprecated: This package is no longer supported. + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + + oauth@0.9.15: + resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==} + + object-hash@2.2.0: + resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} + engines: {node: '>= 6'} + object-hash@3.0.0: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} @@ -1733,6 +2928,10 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + oidc-token-hash@5.2.0: + resolution: {integrity: sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==} + engines: {node: ^10.13.0 || >=12.0.0} + ollama@0.5.18: resolution: {integrity: sha512-lTFqTf9bo7Cd3hpF6CviBe/DEhewjoZYd9N/uCe7O20qYTvGqrNOFOBDj3lbZgFWHUgDv5EeyusYxsZSLS8nvg==} @@ -1758,6 +2957,9 @@ packages: zod: optional: true + openid-client@5.7.1: + resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==} + p-finally@1.0.0: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} @@ -1785,6 +2987,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -1853,6 +3058,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1892,15 +3101,30 @@ packages: postgres-range@1.1.4: resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} + preact-render-to-string@5.2.6: + resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==} + peerDependencies: + preact: '>=10' + + preact@10.28.3: + resolution: {integrity: sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + pretty-format@3.8.0: + resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} + promise-inflight@1.0.1: resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} peerDependencies: @@ -1923,17 +3147,71 @@ packages: pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + redis@4.7.1: resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} @@ -1959,6 +3237,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -1976,14 +3257,32 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2074,14 +3373,44 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwind-merge@3.4.1: + resolution: {integrity: sha512-2OA0rFqWOkITEAOFWSBSApYkDeH9t2B3XSJuI4YztKBzK3mX0737A2qtxDZ7xkw9Zfh0bWl+r34sF3HXV+Ig7Q==} + + tailwindcss@4.1.18: + resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} @@ -2112,13 +3441,28 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + triple-beam@1.4.1: resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} engines: {node: '>= 14.0.0'} @@ -2140,6 +3484,9 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -2153,6 +3500,37 @@ packages: unique-slug@2.0.2: resolution: {integrity: sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2242,6 +3620,10 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -2253,9 +3635,26 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -2313,10 +3712,20 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} @@ -2333,6 +3742,10 @@ packages: snapshots: + '@adobe/css-tools@4.4.4': {} + + '@alloc/quick-lru@5.2.0': {} + '@anthropic-ai/sdk@0.40.1(encoding@0.1.13)': dependencies: '@types/node': 18.19.130 @@ -2345,6 +3758,14 @@ snapshots: transitivePeerDependencies: - encoding + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@azure/abort-controller@2.1.2': dependencies: tslib: 2.8.1 @@ -2459,14 +3880,109 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@babel/parser@7.29.0': dependencies: '@babel/types': 7.29.0 + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/runtime@7.28.6': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -2474,46 +3990,66 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@biomejs/biome@2.3.14': + '@biomejs/biome@2.4.0': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.3.14 - '@biomejs/cli-darwin-x64': 2.3.14 - '@biomejs/cli-linux-arm64': 2.3.14 - '@biomejs/cli-linux-arm64-musl': 2.3.14 - '@biomejs/cli-linux-x64': 2.3.14 - '@biomejs/cli-linux-x64-musl': 2.3.14 - '@biomejs/cli-win32-arm64': 2.3.14 - '@biomejs/cli-win32-x64': 2.3.14 - - '@biomejs/cli-darwin-arm64@2.3.14': + '@biomejs/cli-darwin-arm64': 2.4.0 + '@biomejs/cli-darwin-x64': 2.4.0 + '@biomejs/cli-linux-arm64': 2.4.0 + '@biomejs/cli-linux-arm64-musl': 2.4.0 + '@biomejs/cli-linux-x64': 2.4.0 + '@biomejs/cli-linux-x64-musl': 2.4.0 + '@biomejs/cli-win32-arm64': 2.4.0 + '@biomejs/cli-win32-x64': 2.4.0 + + '@biomejs/cli-darwin-arm64@2.4.0': optional: true - '@biomejs/cli-darwin-x64@2.3.14': + '@biomejs/cli-darwin-x64@2.4.0': optional: true - '@biomejs/cli-linux-arm64-musl@2.3.14': + '@biomejs/cli-linux-arm64-musl@2.4.0': optional: true - '@biomejs/cli-linux-arm64@2.3.14': + '@biomejs/cli-linux-arm64@2.4.0': optional: true - '@biomejs/cli-linux-x64-musl@2.3.14': + '@biomejs/cli-linux-x64-musl@2.4.0': optional: true - '@biomejs/cli-linux-x64@2.3.14': + '@biomejs/cli-linux-x64@2.4.0': optional: true - '@biomejs/cli-win32-arm64@2.3.14': + '@biomejs/cli-win32-arm64@2.4.0': optional: true - '@biomejs/cli-win32-x64@2.3.14': + '@biomejs/cli-win32-x64@2.4.0': optional: true - '@cfworker/json-schema@4.1.1': {} + '@cfworker/json-schema@4.1.1': {} + + '@cloudflare/workers-types@4.20260214.0': {} + + '@colors/colors@1.6.0': {} + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 - '@cloudflare/workers-types@4.20260214.0': {} + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 - '@colors/colors@1.6.0': {} + '@csstools/css-tokenizer@3.0.4': {} '@dabh/diagnostics@2.0.8': dependencies: @@ -2526,7 +4062,7 @@ snapshots: '@discordjs/formatters': 0.6.2 '@discordjs/util': 1.2.0 '@sapphire/shapeshift': 4.0.0 - discord-api-types: 0.38.38 + discord-api-types: 0.38.39 fast-deep-equal: 3.1.3 ts-mixer: 6.0.4 tslib: 2.8.1 @@ -2537,7 +4073,7 @@ snapshots: '@discordjs/formatters@0.6.2': dependencies: - discord-api-types: 0.38.38 + discord-api-types: 0.38.39 '@discordjs/rest@2.6.0': dependencies: @@ -2546,14 +4082,14 @@ snapshots: '@sapphire/async-queue': 1.5.5 '@sapphire/snowflake': 3.5.3 '@vladfrangu/async_event_emitter': 2.4.7 - discord-api-types: 0.38.38 + discord-api-types: 0.38.39 magic-bytes.js: 1.13.0 tslib: 2.8.1 undici: 6.23.0 '@discordjs/util@1.2.0': dependencies: - discord-api-types: 0.38.38 + discord-api-types: 0.38.39 '@discordjs/ws@1.2.3': dependencies: @@ -2563,13 +4099,18 @@ snapshots: '@sapphire/async-queue': 1.5.5 '@types/ws': 8.18.1 '@vladfrangu/async_event_emitter': 2.4.7 - discord-api-types: 0.38.38 + discord-api-types: 0.38.39 tslib: 2.8.1 ws: 8.19.0 transitivePeerDependencies: - bufferutil - utf-8-validate + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.3': optional: true @@ -2648,6 +4189,23 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@floating-ui/core@1.7.4': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.5': + dependencies: + '@floating-ui/core': 1.7.4 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/dom': 1.7.5 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@floating-ui/utils@0.2.10': {} + '@gar/promisify@1.1.3': optional: true @@ -2662,6 +4220,103 @@ snapshots: - supports-color - utf-8-validate + '@img/colour@1.0.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.8.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -2684,10 +4339,20 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.2.0 + '@types/node': 22.19.11 '@types/yargs': 17.0.35 chalk: 4.1.2 + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -2726,6 +4391,32 @@ snapshots: - bufferutil - utf-8-validate + '@next/env@16.1.6': {} + + '@next/swc-darwin-arm64@16.1.6': + optional: true + + '@next/swc-darwin-x64@16.1.6': + optional: true + + '@next/swc-linux-arm64-gnu@16.1.6': + optional: true + + '@next/swc-linux-arm64-musl@16.1.6': + optional: true + + '@next/swc-linux-x64-gnu@16.1.6': + optional: true + + '@next/swc-linux-x64-musl@16.1.6': + optional: true + + '@next/swc-win32-arm64-msvc@16.1.6': + optional: true + + '@next/swc-win32-x64-msvc@16.1.6': + optional: true + '@npmcli/fs@1.1.1': dependencies: '@gar/promisify': 1.1.3 @@ -2738,6 +4429,8 @@ snapshots: rimraf: 3.0.2 optional: true + '@panva/hkdf@1.2.1': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -2773,6 +4466,319 @@ snapshots: '@qdrant/openapi-typescript-fetch@1.2.6': {} + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-context': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context@1.1.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/rect@1.1.1': {} + '@redis/bloom@1.2.0(@redis/client@1.6.1)': dependencies: '@redis/client': 1.6.1 @@ -2799,6 +4805,8 @@ snapshots: dependencies: '@redis/client': 1.6.1 + '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rollup/rollup-android-arm-eabi@4.57.1': optional: true @@ -2932,9 +4940,139 @@ snapshots: - bufferutil - utf-8-validate + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.1.18': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.19.0 + jiti: 2.6.1 + lightningcss: 1.30.2 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.1.18 + + '@tailwindcss/oxide-android-arm64@4.1.18': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.18': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.18': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.18': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.18': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + optional: true + + '@tailwindcss/oxide@4.1.18': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-x64': 4.1.18 + '@tailwindcss/oxide-freebsd-x64': 4.1.18 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-x64-musl': 4.1.18 + '@tailwindcss/oxide-wasm32-wasi': 4.1.18 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 + + '@tailwindcss/postcss@4.1.18': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.1.18 + '@tailwindcss/oxide': 4.1.18 + postcss: 8.5.6 + tailwindcss: 4.1.18 + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tootallnate/once@1.1.2': optional: true + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -2961,30 +5099,42 @@ snapshots: '@types/node-fetch@2.6.13': dependencies: - '@types/node': 25.2.0 + '@types/node': 22.19.11 form-data: 4.0.5 '@types/node@18.19.130': dependencies: undici-types: 5.26.5 - '@types/node@25.2.0': + '@types/node@22.19.11': + dependencies: + undici-types: 6.21.0 + + '@types/node@25.2.3': dependencies: undici-types: 7.16.0 '@types/pg@8.11.0': dependencies: - '@types/node': 25.2.0 + '@types/node': 22.19.11 pg-protocol: 1.11.0 pg-types: 4.1.0 '@types/phoenix@1.6.7': {} + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + '@types/retry@0.12.0': {} '@types/sqlite3@3.1.11': dependencies: - '@types/node': 25.2.0 + '@types/node': 22.19.11 '@types/stack-utils@2.0.3': {} @@ -2994,7 +5144,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 '@types/yargs-parser@21.0.3': {} @@ -3010,7 +5160,33 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.2.0))': + '@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2) + transitivePeerDependencies: + - supports-color + + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@22.19.11)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@22.19.11)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2) + + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -3022,7 +5198,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@25.2.0) + vitest: 4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2) '@vitest/expect@4.0.18': dependencies: @@ -3033,13 +5209,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.2.0))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2) + + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.2.0) + vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2) '@vitest/pretty-format@4.0.18': dependencies: @@ -3112,6 +5296,16 @@ snapshots: readable-stream: 3.6.2 optional: true + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + assertion-error@2.0.1: {} ast-v8-to-istanbul@0.3.11: @@ -3138,6 +5332,8 @@ snapshots: base64-js@1.5.1: {} + baseline-browser-mapping@2.9.19: {} + bignumber.js@9.3.1: {} bindings@1.5.0: @@ -3164,6 +5360,14 @@ snapshots: dependencies: fill-range: 7.1.1 + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001770 + electron-to-chromium: 1.5.286 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer-equal-constant-time@1.0.1: {} buffer@5.7.1: @@ -3211,6 +5415,8 @@ snapshots: camelcase@6.3.0: {} + caniuse-lite@1.0.30001770: {} + chai@6.2.2: {} chalk@4.1.2: @@ -3226,9 +5432,15 @@ snapshots: ci-info@3.9.0: {} + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + clean-stack@2.2.0: optional: true + client-only@0.0.1: {} + cloudflare@4.5.0(encoding@0.1.13): dependencies: '@types/node': 18.19.130 @@ -3241,6 +5453,8 @@ snapshots: transitivePeerDependencies: - encoding + clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} color-convert@2.0.1: @@ -3281,6 +5495,10 @@ snapshots: dependencies: simple-wcswidth: 1.1.2 + convert-source-map@2.0.0: {} + + cookie@0.7.2: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3289,14 +5507,30 @@ snapshots: crypt@0.0.2: {} + css.escape@1.5.1: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + data-uri-to-buffer@4.0.1: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + debug@4.4.3: dependencies: ms: 2.1.3 decamelize@1.2.0: {} + decimal.js@10.6.0: {} + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -3317,8 +5551,12 @@ snapshots: delegates@1.0.0: optional: true + dequal@2.0.3: {} + detect-libc@2.1.2: {} + detect-node-es@1.1.0: {} + diff-sequences@29.6.3: {} digest-fetch@1.3.0: @@ -3326,7 +5564,7 @@ snapshots: base-64: 0.1.0 md5: 2.3.0 - discord-api-types@0.38.38: {} + discord-api-types@0.38.39: {} discord.js@14.25.1: dependencies: @@ -3337,7 +5575,7 @@ snapshots: '@discordjs/util': 1.2.0 '@discordjs/ws': 1.2.3 '@sapphire/snowflake': 3.5.3 - discord-api-types: 0.38.38 + discord-api-types: 0.38.39 fast-deep-equal: 3.1.3 lodash.snakecase: 4.1.1 magic-bytes.js: 1.13.0 @@ -3347,7 +5585,11 @@ snapshots: - bufferutil - utf-8-validate - dotenv@17.2.4: {} + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dotenv@17.3.1: {} dunder-proto@1.0.1: dependencies: @@ -3361,6 +5603,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + electron-to-chromium@1.5.286: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -3376,6 +5620,13 @@ snapshots: dependencies: once: 1.4.0 + enhanced-resolve@5.19.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + entities@6.0.1: {} + env-paths@2.2.1: optional: true @@ -3428,6 +5679,8 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 + escalade@3.2.0: {} + escape-string-regexp@2.0.0: {} estree-walker@3.0.3: @@ -3550,6 +5803,8 @@ snapshots: generic-pool@3.9.0: {} + gensync@1.0.0-beta.2: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3563,6 +5818,8 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -3643,6 +5900,10 @@ snapshots: dependencies: function-bind: 1.1.2 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + html-escaper@2.0.2: {} http-cache-semantics@4.2.0: @@ -3688,15 +5949,13 @@ snapshots: iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 - optional: true ieee754@1.2.1: {} imurmurhash@0.1.4: optional: true - indent-string@4.0.0: - optional: true + indent-string@4.0.0: {} infer-owner@1.0.4: optional: true @@ -3731,6 +5990,8 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-stream@2.0.1: {} is-wsl@3.1.1: @@ -3789,12 +6050,16 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.2.0 + '@types/node': 22.19.11 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 picomatch: 2.3.1 + jiti@2.6.1: {} + + jose@4.15.9: {} + js-tiktoken@1.0.21: dependencies: base64-js: 1.5.1 @@ -3803,10 +6068,41 @@ snapshots: js-tokens@4.0.0: {} + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.19.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + json-bigint@1.0.0: dependencies: bignumber.js: 9.3.1 + json5@2.2.3: {} + jsonwebtoken@9.0.3: dependencies: jws: 4.0.1 @@ -3844,6 +6140,55 @@ snapshots: optionalDependencies: openai: 4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76) + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -3875,10 +6220,19 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + lru-cache@6.0.0: dependencies: yallist: 4.0.0 - optional: true + + lucide-react@0.525.0(react@19.2.4): + dependencies: + react: 19.2.4 + + lz-string@1.5.0: {} magic-bytes.js@1.13.0: {} @@ -3970,6 +6324,8 @@ snapshots: mimic-response@3.1.0: {} + min-indent@1.0.1: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -4054,6 +6410,45 @@ snapshots: neo4j-driver-core: 5.28.3 rxjs: 7.8.2 + next-auth@4.24.13(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@babel/runtime': 7.28.6 + '@panva/hkdf': 1.2.1 + cookie: 0.7.2 + jose: 4.15.9 + next: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + oauth: 0.9.15 + openid-client: 5.7.1 + preact: 10.28.3 + preact-render-to-string: 5.2.6(preact@10.28.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + uuid: 8.3.2 + + next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@next/env': 16.1.6 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001770 + postcss: 8.4.31 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) + optionalDependencies: + '@next/swc-darwin-arm64': 16.1.6 + '@next/swc-darwin-x64': 16.1.6 + '@next/swc-linux-arm64-gnu': 16.1.6 + '@next/swc-linux-arm64-musl': 16.1.6 + '@next/swc-linux-x64-gnu': 16.1.6 + '@next/swc-linux-x64-musl': 16.1.6 + '@next/swc-win32-arm64-msvc': 16.1.6 + '@next/swc-win32-x64-msvc': 16.1.6 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + node-abi@3.87.0: dependencies: semver: 7.7.4 @@ -4091,6 +6486,8 @@ snapshots: - supports-color optional: true + node-releases@2.0.27: {} + nopt@5.0.0: dependencies: abbrev: 1.1.1 @@ -4104,12 +6501,20 @@ snapshots: set-blocking: 2.0.0 optional: true + nwsapi@2.2.23: {} + + oauth@0.9.15: {} + + object-hash@2.2.0: {} + object-hash@3.0.0: {} obuf@1.1.2: {} obug@2.1.1: {} + oidc-token-hash@5.2.0: {} + ollama@0.5.18: dependencies: whatwg-fetch: 3.6.20 @@ -4144,6 +6549,13 @@ snapshots: transitivePeerDependencies: - encoding + openid-client@5.7.1: + dependencies: + jose: 4.15.9 + lru-cache: 6.0.0 + object-hash: 2.2.0 + oidc-token-hash: 5.2.0 + p-finally@1.0.0: {} p-map@4.0.0: @@ -4171,6 +6583,10 @@ snapshots: package-json-from-dist@1.0.1: {} + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-is-absolute@1.0.1: optional: true @@ -4236,6 +6652,12 @@ snapshots: picomatch@4.0.3: {} + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -4264,6 +6686,13 @@ snapshots: postgres-range@1.1.4: {} + preact-render-to-string@5.2.6(preact@10.28.3): + dependencies: + preact: 10.28.3 + pretty-format: 3.8.0 + + preact@10.28.3: {} + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -4279,12 +6708,20 @@ snapshots: tar-fs: 2.1.4 tunnel-agent: 0.6.0 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-format@29.7.0: dependencies: '@jest/schemas': 29.6.3 ansi-styles: 5.2.0 react-is: 18.3.1 + pretty-format@3.8.0: {} + promise-inflight@1.0.1: optional: true @@ -4306,7 +6743,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 25.2.0 + '@types/node': 22.19.11 long: 5.3.2 proxy-from-env@1.1.0: {} @@ -4316,6 +6753,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + punycode@2.3.1: {} + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -4323,14 +6762,57 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-is@17.0.2: {} + react-is@18.3.1: {} + react-refresh@0.17.0: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + get-nonce: 1.0.1 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react@19.2.4: {} + readable-stream@3.6.2: dependencies: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + redis@4.7.1: dependencies: '@redis/bloom': 1.2.0(@redis/client@1.6.1) @@ -4385,6 +6867,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.57.1 fsevents: 2.3.3 + rrweb-cssom@0.8.0: {} + run-applescript@7.1.0: {} rxjs@7.8.2: @@ -4395,14 +6879,55 @@ snapshots: safe-stable-stringify@2.5.0: {} - safer-buffer@2.1.2: - optional: true + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + semver@6.3.1: {} semver@7.7.4: {} + server-only@0.0.1: {} + set-blocking@2.0.0: optional: true + sharp@0.34.5: + dependencies: + '@img/colour': 1.0.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -4501,12 +7026,31 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@2.0.1: {} + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): + dependencies: + client-only: 0.0.1 + react: 19.2.4 + optionalDependencies: + '@babel/core': 7.29.0 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 + symbol-tree@3.2.4: {} + + tailwind-merge@3.4.1: {} + + tailwindcss@4.1.18: {} + + tapable@2.3.0: {} + tar-fs@2.1.4: dependencies: chownr: 1.1.4 @@ -4544,12 +7088,26 @@ snapshots: tinyrainbow@3.0.3: {} + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + tr46@0.0.3: {} + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + triple-beam@1.4.1: {} ts-mixer@6.0.4: {} @@ -4564,6 +7122,8 @@ snapshots: undici-types@5.26.5: {} + undici-types@6.21.0: {} + undici-types@7.16.0: {} undici@6.23.0: {} @@ -4578,6 +7138,31 @@ snapshots: imurmurhash: 0.1.4 optional: true + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + util-deprecate@1.0.2: {} uuid@10.0.0: {} @@ -4586,7 +7171,21 @@ snapshots: uuid@9.0.1: {} - vite@7.3.1(@types/node@25.2.0): + vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.11 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + + vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -4595,13 +7194,53 @@ snapshots: rollup: 4.57.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + + vitest@4.0.18(@types/node@22.19.11)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.11 + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml - vitest@4.0.18(@types/node@25.2.0): + vitest@4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.0)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -4618,10 +7257,11 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.2.0) + vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 + jsdom: 26.1.0 transitivePeerDependencies: - jiti - less @@ -4635,14 +7275,31 @@ snapshots: - tsx - yaml + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-streams-polyfill@3.3.3: {} web-streams-polyfill@4.0.0-beta.3: {} webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-fetch@3.6.20: {} + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -4710,8 +7367,14 @@ snapshots: dependencies: is-wsl: 3.1.1 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xtend@4.0.2: {} + yallist@3.1.1: {} + yallist@4.0.0: {} zod-to-json-schema@3.25.1(zod@3.25.76): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..92a7e8bd --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "web" diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 00000000..f17f052a --- /dev/null +++ b/web/.env.example @@ -0,0 +1,22 @@ +# Discord OAuth2 credentials (from Discord Developer Portal) +DISCORD_CLIENT_ID=your_discord_client_id +DISCORD_CLIENT_SECRET=your_discord_client_secret + +# NextAuth.js secret (generate with: openssl rand -base64 32) +NEXTAUTH_SECRET=CHANGE_ME_generate_with_openssl_rand_base64_32 + +# NextAuth.js URL (the canonical URL of your site) +NEXTAUTH_URL=http://localhost:3000 + +# Bot API URL (for fetching bot guild list) +# In development, this can be left empty — the dashboard will show all user guilds +# In production, point to the bot's API endpoint +BOT_API_URL= + +# Shared secret for authenticating requests to the bot API +# Generate with: openssl rand -base64 32 +# Must match the secret configured on the bot API side +BOT_API_SECRET= + +# Public Discord Client ID (for the "Add to Server" button on the landing page) +NEXT_PUBLIC_DISCORD_CLIENT_ID=your_discord_client_id diff --git a/web/.env.local.example b/web/.env.local.example new file mode 100644 index 00000000..ca3df2cc --- /dev/null +++ b/web/.env.local.example @@ -0,0 +1,11 @@ +# Copy this to .env.local for local development +# cp .env.local.example .env.local + +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +# Generate with: openssl rand -base64 32 +NEXTAUTH_SECRET= +NEXTAUTH_URL=http://localhost:3000 +BOT_API_URL= +BOT_API_SECRET= +NEXT_PUBLIC_DISCORD_CLIENT_ID= diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 00000000..e68f4667 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,63 @@ +# syntax=docker/dockerfile:1 + +# --- Dependencies --- +FROM node:22-alpine AS deps +RUN corepack enable +WORKDIR /app + +# Build context: Must be the monorepo root (not web/). The Dockerfile expects +# pnpm-workspace.yaml, pnpm-lock.yaml, and package.json at the root level, +# plus web/package.json for the dashboard package. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY web/package.json ./web/ +RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ + pnpm install --frozen-lockfile --filter bills-bot-web + +# --- Builder --- +FROM node:22-alpine AS builder +RUN corepack enable +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/web/node_modules ./web/node_modules +COPY . . + +# Build args for env vars needed at build time +ARG NEXT_PUBLIC_DISCORD_CLIENT_ID +ENV NEXT_PUBLIC_DISCORD_CLIENT_ID=$NEXT_PUBLIC_DISCORD_CLIENT_ID + +ENV NEXT_TELEMETRY_DISABLED=1 +WORKDIR /app/web +RUN pnpm build && \ + # Verify standalone output path — pnpm workspaces nest under the package dir + test -f .next/standalone/web/server.js || \ + (echo "ERROR: Expected .next/standalone/web/server.js not found. Check Next.js standalone output." && exit 1) + +# --- Runner --- +FROM node:22-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +# Leverage Next.js standalone output. +# In a pnpm workspace monorepo, standalone output nests the app under its +# package directory (web/), so server.js lives at web/server.js. +COPY --from=builder --chown=nextjs:nodejs /app/web/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/web/.next/static ./web/.next/static +COPY --from=builder --chown=nextjs:nodejs /app/web/public ./web/public + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1 + +CMD ["node", "web/server.js"] diff --git a/web/components.json b/web/components.json new file mode 100644 index 00000000..f4fb4bea --- /dev/null +++ b/web/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/web/next-env.d.ts b/web/next-env.d.ts new file mode 100644 index 00000000..9edff1c7 --- /dev/null +++ b/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/web/next.config.ts b/web/next.config.ts new file mode 100644 index 00000000..635eb9fb --- /dev/null +++ b/web/next.config.ts @@ -0,0 +1,46 @@ +import type { NextConfig } from "next"; + +// TODO: Implement nonce-based CSP as a separate task. +// script-src 'self' without 'unsafe-inline' breaks Next.js RSC streaming/hydration. +const securityHeaders = [ + { + key: "X-Frame-Options", + value: "DENY", + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, +]; + +const nextConfig: NextConfig = { + output: "standalone", + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "cdn.discordapp.com", + pathname: "/{avatars,icons,embed}/**", + }, + ], + }, + async headers() { + return [ + { + // Apply security headers to all routes + source: "/(.*)", + headers: securityHeaders, + }, + ]; + }, +}; + +export default nextConfig; diff --git a/web/package.json b/web/package.json new file mode 100644 index 00000000..05028ae7 --- /dev/null +++ b/web/package.json @@ -0,0 +1,46 @@ +{ + "name": "bills-bot-web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.525.0", + "next": "^16.1.6", + "next-auth": "^4.24.13", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "server-only": "^0.0.1", + "tailwind-merge": "^3.4.1" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.18", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^22.19.11", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^4.7.0", + "@vitest/coverage-v8": "^4.0.18", + "jsdom": "^26.1.0", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + } +} diff --git a/web/postcss.config.mjs b/web/postcss.config.mjs new file mode 100644 index 00000000..61e36849 --- /dev/null +++ b/web/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/web/public/.gitkeep b/web/public/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/web/src/app/api/auth/[...nextauth]/route.ts b/web/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 00000000..7b38c1bb --- /dev/null +++ b/web/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import NextAuth from "next-auth"; +import { authOptions } from "@/lib/auth"; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/web/src/app/api/guilds/route.ts b/web/src/app/api/guilds/route.ts new file mode 100644 index 00000000..0b370c99 --- /dev/null +++ b/web/src/app/api/guilds/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { getToken } from "next-auth/jwt"; +import { getMutualGuilds } from "@/lib/discord.server"; +import { logger } from "@/lib/logger"; + +export const dynamic = "force-dynamic"; + +/** Request timeout for the guilds endpoint (10 seconds). */ +const REQUEST_TIMEOUT_MS = 10_000; + +export async function GET(request: NextRequest) { + const token = await getToken({ req: request }); + + if (!token?.accessToken) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // If the JWT refresh previously failed, don't send a stale token to Discord + if (token.error === "RefreshTokenError") { + return NextResponse.json( + { error: "Token expired. Please sign in again." }, + { status: 401 }, + ); + } + + try { + const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + const guilds = await getMutualGuilds(token.accessToken as string, signal); + return NextResponse.json(guilds); + } catch (error) { + logger.error("[api/guilds] Failed to fetch guilds:", error); + return NextResponse.json( + { error: "Failed to fetch guilds" }, + { status: 500 }, + ); + } +} diff --git a/web/src/app/api/health/route.ts b/web/src/app/api/health/route.ts new file mode 100644 index 00000000..7a1aa47c --- /dev/null +++ b/web/src/app/api/health/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; + +/** + * Health check endpoint for container orchestration (Docker HEALTHCHECK, Railway). + * Returns 200 with a simple JSON payload. No authentication required. + */ +export function GET() { + return NextResponse.json({ status: "ok", timestamp: new Date().toISOString() }); +} diff --git a/web/src/app/dashboard/error.tsx b/web/src/app/dashboard/error.tsx new file mode 100644 index 00000000..4744a468 --- /dev/null +++ b/web/src/app/dashboard/error.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { ErrorCard } from "@/components/error-card"; +import { logger } from "@/lib/logger"; + +export default function DashboardError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + logger.error("[dashboard-error-boundary]", error); + }, [error]); + + return ( +
+ + + +
+ } + /> + + ); +} diff --git a/web/src/app/dashboard/layout.tsx b/web/src/app/dashboard/layout.tsx new file mode 100644 index 00000000..06deae0f --- /dev/null +++ b/web/src/app/dashboard/layout.tsx @@ -0,0 +1,19 @@ +import { redirect } from "next/navigation"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { DashboardShell } from "@/components/layout/dashboard-shell"; + +export default async function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + // Defense-in-depth: server-side auth check in addition to proxy.ts. + // Prevents unauthenticated access if the proxy/middleware layer is bypassed. + const session = await getServerSession(authOptions); + if (!session) { + redirect("/login"); + } + + return {children}; +} diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx new file mode 100644 index 00000000..318e677d --- /dev/null +++ b/web/src/app/dashboard/page.tsx @@ -0,0 +1,90 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { MessageSquare, Shield, Users, Activity } from "lucide-react"; + +const stats = [ + { + title: "Members", + value: "—", + description: "Total server members", + icon: Users, + }, + { + title: "Mod Cases", + value: "—", + description: "Total moderation actions", + icon: Shield, + }, + { + title: "Messages", + value: "—", + description: "AI messages this week", + icon: MessageSquare, + }, + { + title: "Uptime", + value: "—", + description: "Bot uptime", + icon: Activity, + }, +]; + +export default function DashboardPage() { + return ( +
+
+

Dashboard

+

+ Overview of your Bill Bot server. +

+
+ +
+ {stats.map((stat) => ( + + + + {stat.title} + + + + +
{stat.value}
+ + {stat.description} + +
+
+ ))} +
+ + + + Getting Started + + Welcome to the Bill Bot dashboard. This is the foundation — more + features are coming soon. + + + +
+

+ Use the sidebar to navigate between sections. Select a server + from the dropdown to manage its settings. +

+

+ The dashboard will show real-time stats and management tools as + they're built out. For now, you can verify your Discord + authentication and server access are working correctly. +

+
+
+
+
+ ); +} diff --git a/web/src/app/error.tsx b/web/src/app/error.tsx new file mode 100644 index 00000000..e8c48d35 --- /dev/null +++ b/web/src/app/error.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { ErrorCard } from "@/components/error-card"; +import { logger } from "@/lib/logger"; + +export default function RootError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + logger.error("[error-boundary]", error); + }, [error]); + + return ( +
+ Try Again} + /> +
+ ); +} diff --git a/web/src/app/global-error.tsx b/web/src/app/global-error.tsx new file mode 100644 index 00000000..8263fe86 --- /dev/null +++ b/web/src/app/global-error.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect } from "react"; +import { logger } from "@/lib/logger"; + +/** + * Root-level error boundary for Next.js App Router. + * This catches errors that propagate past the root layout, + * so it must render its own and tags. + */ +export default function RootError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + logger.error("[global-error-boundary]", error); + }, [error]); + + return ( + + +
+
+

+ Something went wrong +

+

+ A critical error occurred. Please try again. +

+ {error.digest && ( +

+ Error ID: {error.digest} +

+ )} + +
+
+ + + ); +} diff --git a/web/src/app/globals.css b/web/src/app/globals.css new file mode 100644 index 00000000..b845b316 --- /dev/null +++ b/web/src/app/globals.css @@ -0,0 +1,102 @@ +@import "tailwindcss"; + +@theme { + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + --color-discord: #5865F2; + --color-discord-dark: #454FBF; + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); + --animate-accordion-down: accordion-down 0.2s ease-out; + --animate-accordion-up: accordion-up 0.2s ease-out; + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } + } + @keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } + } +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 5.9% 10%; + --radius: 0.5rem; + } + + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx new file mode 100644 index 00000000..d45577ec --- /dev/null +++ b/web/src/app/layout.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import { Providers } from "@/components/providers"; +import "./globals.css"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Bill Bot Dashboard", + description: + "Manage your Bill Bot Discord server — moderation, AI chat, configuration, and more.", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx new file mode 100644 index 00000000..fdd8d115 --- /dev/null +++ b/web/src/app/login/page.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { Suspense, useEffect } from "react"; +import { signIn, useSession } from "next-auth/react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +function LoginForm() { + const { data: session, status } = useSession(); + const router = useRouter(); + const searchParams = useSearchParams(); + const rawCallbackUrl = searchParams.get("callbackUrl"); + // Validate callbackUrl is a safe relative path to prevent open redirects. + // Reject absolute URLs, protocol-relative URLs (//evil.com), and missing values. + const callbackUrl = + rawCallbackUrl && rawCallbackUrl.startsWith("/") && !rawCallbackUrl.startsWith("//") + ? rawCallbackUrl + : "/dashboard"; + + useEffect(() => { + if (session) { + if (session.error === "RefreshTokenError") { + // RefreshTokenError is handled centrally by the Header component + // (which has a signingOut guard ref to prevent duplicates). + // Do NOT call signOut here to avoid a race condition. + return; + } + router.push(callbackUrl); + } + }, [session, router, callbackUrl]); + + // Show spinner while session is loading or user is already authenticated (redirecting). + // Don't show spinner if the session has a token refresh error — show the login form instead. + if (status === "loading" || (session && !session.error)) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+ + +
+ B +
+ Welcome to Bill Bot + + Sign in with your Discord account to manage your server. + +
+ + +

+ We'll only access your Discord profile and server list. +

+
+
+
+ ); +} + +export default function LoginPage() { + return ( + +
Loading...
+ + } + > + +
+ ); +} diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx new file mode 100644 index 00000000..ac01db07 --- /dev/null +++ b/web/src/app/page.tsx @@ -0,0 +1,187 @@ +import Link from "next/link"; +import { + Bot, + MessageSquare, + Shield, + Sparkles, + Users, + Zap, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { getBotInviteUrl } from "@/lib/discord"; + +const features = [ + { + icon: MessageSquare, + title: "AI Chat", + description: + "Powered by Claude via OpenClaw — natural conversations, context-aware responses, and organic chat participation.", + }, + { + icon: Shield, + title: "Moderation", + description: + "Comprehensive moderation toolkit — warns, kicks, bans, timeouts, tempbans with full case tracking and mod logs.", + }, + { + icon: Users, + title: "Welcome Messages", + description: + "Dynamic, AI-generated welcome messages that make every new member feel special.", + }, + { + icon: Zap, + title: "Spam Detection", + description: + "Automatic spam and scam detection to keep your community safe.", + }, + { + icon: Sparkles, + title: "Runtime Config", + description: + "Configure everything on the fly — no restarts needed. Database-backed config with slash command management.", + }, + { + icon: Bot, + title: "Web Dashboard", + description: + "This dashboard — manage your bot settings, view mod logs, and configure your server from any device.", + }, +]; + +/** Render an "Add to Server" button — disabled/hidden when CLIENT_ID is unset. */ +function InviteButton({ size = "sm", className }: { size?: "sm" | "lg"; className?: string }) { + const url = getBotInviteUrl(); + if (!url) return null; + return ( + + ); +} + +export default function LandingPage() { + return ( +
+ {/* Navbar */} +
+
+
+
+ B +
+ Bill Bot +
+ +
+
+ + {/* Hero */} +
+
+ B +
+

+ Bill Bot +

+

+ The AI-powered Discord bot for the Volvox community. Moderation, AI + chat, dynamic welcomes, spam detection, and a fully configurable web + dashboard. +

+
+ + +
+
+ + {/* Features */} +
+
+

+ Everything you need +

+

+ A full-featured Discord bot with a modern web dashboard. +

+
+
+ {features.map((feature) => ( + + +
+ +
+ {feature.title} +
+ + + {feature.description} + + +
+ ))} +
+
+ + {/* CTA */} +
+
+

+ Ready to get started? +

+

+ Add Bill Bot to your Discord server and manage everything from this + dashboard. +

+ +
+
+ + {/* Footer */} + +
+ ); +} diff --git a/web/src/components/error-card.tsx b/web/src/components/error-card.tsx new file mode 100644 index 00000000..0294143d --- /dev/null +++ b/web/src/components/error-card.tsx @@ -0,0 +1,37 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +interface ErrorCardProps { + title: string; + description: string; + digest?: string; + actions: React.ReactNode; +} + +/** + * Shared error UI card used by both the root error boundary + * and the dashboard error boundary. + */ +export function ErrorCard({ title, description, digest, actions }: ErrorCardProps) { + return ( + + + {title} + {description} + + + {digest && ( +

+ Error ID: {digest} +

+ )} + {actions} +
+
+ ); +} diff --git a/web/src/components/layout/dashboard-shell.tsx b/web/src/components/layout/dashboard-shell.tsx new file mode 100644 index 00000000..e81eb0f7 --- /dev/null +++ b/web/src/components/layout/dashboard-shell.tsx @@ -0,0 +1,33 @@ +import { Header } from "./header"; +import { Sidebar } from "./sidebar"; +import { ServerSelector } from "./server-selector"; + +interface DashboardShellProps { + children: React.ReactNode; +} + +/** + * Server component shell for the dashboard layout. + * Mobile sidebar toggle is in its own client component (MobileSidebar) + * which is rendered inside the Header. + */ +export function DashboardShell({ children }: DashboardShellProps) { + return ( +
+
+ +
+ {/* Desktop sidebar */} + + + {/* Main content */} +
{children}
+
+
+ ); +} diff --git a/web/src/components/layout/header.tsx b/web/src/components/layout/header.tsx new file mode 100644 index 00000000..cf16b878 --- /dev/null +++ b/web/src/components/layout/header.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import Link from "next/link"; +import { signOut, useSession } from "next-auth/react"; +import { LogOut, ExternalLink } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Skeleton } from "@/components/ui/skeleton"; +import { MobileSidebar } from "./mobile-sidebar"; + +export function Header() { + const { data: session, status } = useSession(); + const signingOut = useRef(false); + + // Single handler for RefreshTokenError — sign out and redirect to login. + // session.error is set by the JWT callback when refreshDiscordToken fails. + // Note: This is the ONLY RefreshTokenError handler in the app (providers.tsx + // delegates to this component to avoid race conditions). + // The signingOut guard prevents duplicate sign-out attempts when the session + // refetches and re-triggers this effect. + useEffect(() => { + if (session?.error === "RefreshTokenError" && !signingOut.current) { + signingOut.current = true; + signOut({ callbackUrl: "/login" }); + } + }, [session?.error]); + + return ( +
+ + +
+
+ B +
+ + Bill Bot Dashboard + +
+ +
+ {status === "loading" && ( + + )} + {status === "unauthenticated" && ( + + )} + {session?.user && ( + + + + + + +
+

+ {session.user.name} +

+
+
+ + + + + Documentation + + + + signOut({ callbackUrl: "/" })} + > + + Sign out + +
+
+ )} +
+
+ ); +} diff --git a/web/src/components/layout/mobile-sidebar.tsx b/web/src/components/layout/mobile-sidebar.tsx new file mode 100644 index 00000000..3edc497d --- /dev/null +++ b/web/src/components/layout/mobile-sidebar.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useState } from "react"; +import { Menu } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { Sidebar } from "./sidebar"; +import { ServerSelector } from "./server-selector"; + +/** + * Client component that manages the mobile sidebar sheet toggle. + * Extracted so the parent DashboardShell can be a server component. + */ +export function MobileSidebar() { + const [open, setOpen] = useState(false); + + return ( + <> + + + + + + Navigation + +
+ +
+ setOpen(false)} /> +
+
+ + ); +} diff --git a/web/src/components/layout/server-selector.tsx b/web/src/components/layout/server-selector.tsx new file mode 100644 index 00000000..fdf2de5e --- /dev/null +++ b/web/src/components/layout/server-selector.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { useEffect, useState, useCallback, useRef } from "react"; +import Image from "next/image"; +import { ChevronsUpDown, Server, RefreshCw, Bot } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { MutualGuild } from "@/types/discord"; +import { getBotInviteUrl, getGuildIconUrl } from "@/lib/discord"; +import { cn } from "@/lib/utils"; + +interface ServerSelectorProps { + className?: string; +} + +const SELECTED_GUILD_KEY = "bills-bot-selected-guild"; + +export function ServerSelector({ className }: ServerSelectorProps) { + const [guilds, setGuilds] = useState([]); + const [selectedGuild, setSelectedGuild] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const abortControllerRef = useRef(null); + + // Persist selected guild to localStorage + const selectGuild = (guild: MutualGuild) => { + setSelectedGuild(guild); + try { + localStorage.setItem(SELECTED_GUILD_KEY, guild.id); + } catch { + // localStorage may be unavailable (e.g. incognito) + } + }; + + const loadGuilds = useCallback(async () => { + // Abort any previous in-flight request before starting a new one. + // Always uses the ref-based controller so both the initial mount + // and retry button share a single cancellation path. + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + + setLoading(true); + setError(false); + try { + const response = await fetch("/api/guilds", { signal: controller.signal }); + if (response.status === 401) { + // Auth failure — redirect to login instead of showing a misleading retry + window.location.href = "/login"; + return; + } + if (!response.ok) throw new Error("Failed to fetch"); + const data: unknown = await response.json(); + if (!Array.isArray(data)) throw new Error("Invalid response: expected array"); + const fetchedGuilds = data as MutualGuild[]; + setGuilds(fetchedGuilds); + + // Restore previously selected guild from localStorage + let restored = false; + try { + const savedId = localStorage.getItem(SELECTED_GUILD_KEY); + if (savedId) { + const saved = data.find((g: MutualGuild) => g.id === savedId); + if (saved) { + setSelectedGuild(saved); + restored = true; + } + } + } catch { + // localStorage unavailable + } + + if (!restored && data.length > 0) { + setSelectedGuild(data[0]); + } + } catch (err) { + // Don't treat aborted fetches as errors + if (err instanceof DOMException && err.name === "AbortError") return; + setError(true); + } finally { + // Only reset loading if this request is still the current one. + // When loadGuilds is called again (e.g. retry), the previous request + // is aborted and a new controller replaces the ref. Without this + // guard the aborted request's finally block would set loading=false, + // cancelling out the new request's loading=true. + if (abortControllerRef.current === controller) { + setLoading(false); + } + } + }, []); + + useEffect(() => { + loadGuilds(); + return () => abortControllerRef.current?.abort(); + }, [loadGuilds]); + + if (loading) { + return ( +
+ + Loading servers... +
+ ); + } + + // Error state — allow retry + if (error) { + return ( +
+ Failed to load servers + +
+ ); + } + + // Empty state — distinguish between "no mutual servers" and "no guilds at all" + if (guilds.length === 0) { + const inviteUrl = getBotInviteUrl(); + return ( +
+ + No mutual servers + + Bill Bot isn't in any of your Discord servers yet. + + {inviteUrl ? ( + + + + ) : ( + + Ask a server admin to add the bot, or check that{" "} + NEXT_PUBLIC_DISCORD_CLIENT_ID{" "} + is set for the invite link. + + )} +
+ ); + } + + return ( + + + + + + Your Servers + + {guilds.map((guild) => ( + selectGuild(guild)} + className="flex items-center gap-2" + > + {guild.icon ? ( + {guild.name} + ) : ( + + )} + {guild.name} + + ))} + + + ); +} diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx new file mode 100644 index 00000000..886ea52f --- /dev/null +++ b/web/src/components/layout/sidebar.tsx @@ -0,0 +1,92 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + LayoutDashboard, + Settings, + Shield, + MessageSquare, + Users, + Bot, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Separator } from "@/components/ui/separator"; + +const navigation = [ + { + name: "Overview", + href: "/dashboard", + icon: LayoutDashboard, + }, + { + name: "Moderation", + href: "/dashboard/moderation", + icon: Shield, + }, + { + name: "AI Chat", + href: "/dashboard/ai", + icon: MessageSquare, + }, + { + name: "Members", + href: "/dashboard/members", + icon: Users, + }, + { + name: "Bot Config", + href: "/dashboard/config", + icon: Bot, + }, + { + name: "Settings", + href: "/dashboard/settings", + icon: Settings, + }, +]; + +interface SidebarProps { + className?: string; + onNavClick?: () => void; +} + +export function Sidebar({ className, onNavClick }: SidebarProps) { + const pathname = usePathname(); + + return ( +
+
+

+ Navigation +

+ + +
+
+ ); +} diff --git a/web/src/components/providers.tsx b/web/src/components/providers.tsx new file mode 100644 index 00000000..811534a6 --- /dev/null +++ b/web/src/components/providers.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { SessionProvider } from "next-auth/react"; +import type { ReactNode } from "react"; + +/** + * Root provider wrapper. + * Session error handling (e.g. RefreshTokenError) is handled by the Header + * component which signs out and redirects to /login. + */ +export function Providers({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/web/src/components/ui/avatar.tsx b/web/src/components/ui/avatar.tsx new file mode 100644 index 00000000..145b1268 --- /dev/null +++ b/web/src/components/ui/avatar.tsx @@ -0,0 +1,49 @@ +"use client"; + +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; +import { cn } from "@/lib/utils"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx new file mode 100644 index 00000000..51b0ef82 --- /dev/null +++ b/web/src/components/ui/button.tsx @@ -0,0 +1,58 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + discord: + "bg-discord text-white shadow hover:bg-discord-dark", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/web/src/components/ui/card.tsx b/web/src/components/ui/card.tsx new file mode 100644 index 00000000..4de26485 --- /dev/null +++ b/web/src/components/ui/card.tsx @@ -0,0 +1,82 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLHeadingElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = "CardFooter"; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/web/src/components/ui/dropdown-menu.tsx b/web/src/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..2bd31810 --- /dev/null +++ b/web/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,87 @@ +"use client"; + +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { cn } from "@/lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +const DropdownMenuGroup = DropdownMenuPrimitive.Group; +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuLabel, + DropdownMenuGroup, + DropdownMenuPortal, +}; diff --git a/web/src/components/ui/separator.tsx b/web/src/components/ui/separator.tsx new file mode 100644 index 00000000..78bb5cf6 --- /dev/null +++ b/web/src/components/ui/separator.tsx @@ -0,0 +1,30 @@ +"use client"; + +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import { cn } from "@/lib/utils"; + +/** + * Separator component. + * React 19 supports ref as a regular prop — forwardRef is no longer needed. + */ +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentPropsWithRef) { + return ( + + ); +} + +export { Separator }; diff --git a/web/src/components/ui/sheet.tsx b/web/src/components/ui/sheet.tsx new file mode 100644 index 00000000..2c1fd9e5 --- /dev/null +++ b/web/src/components/ui/sheet.tsx @@ -0,0 +1,121 @@ +"use client"; + +import * as React from "react"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import { cva, type VariantProps } from "class-variance-authority"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const Sheet = SheetPrimitive.Root; +const SheetTrigger = SheetPrimitive.Trigger; +const SheetClose = SheetPrimitive.Close; +const SheetPortal = SheetPrimitive.Portal; + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + }, +); + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +SheetContent.displayName = SheetPrimitive.Content.displayName; + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +SheetHeader.displayName = "SheetHeader"; + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetTitle.displayName = SheetPrimitive.Title.displayName; + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetDescription.displayName = SheetPrimitive.Description.displayName; + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +}; diff --git a/web/src/components/ui/skeleton.tsx b/web/src/components/ui/skeleton.tsx new file mode 100644 index 00000000..f0db0fa6 --- /dev/null +++ b/web/src/components/ui/skeleton.tsx @@ -0,0 +1,12 @@ +import { cn } from "@/lib/utils"; + +function Skeleton({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +export { Skeleton }; diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts new file mode 100644 index 00000000..9a863d12 --- /dev/null +++ b/web/src/lib/auth.ts @@ -0,0 +1,178 @@ +import type { AuthOptions } from "next-auth"; +import DiscordProvider from "next-auth/providers/discord"; +import { logger } from "@/lib/logger"; + +// --- Runtime validation --- + +const secret = process.env.NEXTAUTH_SECRET ?? ""; +const PLACEHOLDER_PATTERN = /change|placeholder|example|replace.?me/i; +if (secret.length < 32 || PLACEHOLDER_PATTERN.test(secret)) { + throw new Error( + "[auth] NEXTAUTH_SECRET must be at least 32 characters and not a placeholder value. " + + "Generate one with: openssl rand -base64 48", + ); +} + +if (!process.env.DISCORD_CLIENT_ID || !process.env.DISCORD_CLIENT_SECRET) { + throw new Error( + "[auth] DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET must be set. " + + "Create an OAuth2 application at https://discord.com/developers/applications", + ); +} + +if (process.env.BOT_API_URL && !process.env.BOT_API_SECRET) { + logger.warn( + "[auth] BOT_API_URL is set but BOT_API_SECRET is missing. " + + "Requests to the bot API will be unauthenticated. " + + "Set BOT_API_SECRET to secure bot API communication.", + ); +} + +/** + * Discord OAuth2 scopes needed for the dashboard. + * - identify: basic user info (id, username, avatar) + * - guilds: list of guilds the user is in + */ +const DISCORD_SCOPES = "identify guilds"; + +/** + * Refresh a Discord OAuth2 access token using the refresh token. + * Returns updated token fields or the original token with an error flag. + * + * Exported for testing; not intended for direct use outside auth callbacks. + */ +export async function refreshDiscordToken(token: Record): Promise> { + if (!token.refreshToken || typeof token.refreshToken !== "string") { + logger.warn("[auth] Cannot refresh Discord token: refreshToken is missing or invalid"); + return { ...token, error: "RefreshTokenError" }; + } + + const params = new URLSearchParams({ + client_id: process.env.DISCORD_CLIENT_ID!, + client_secret: process.env.DISCORD_CLIENT_SECRET!, + grant_type: "refresh_token", + refresh_token: token.refreshToken as string, + }); + + let response: Response; + try { + response = await fetch("https://discord.com/api/v10/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }); + } catch (error) { + logger.error("[auth] Network error refreshing Discord token:", error); + return { ...token, error: "RefreshTokenError" }; + } + + if (!response.ok) { + logger.error( + `[auth] Failed to refresh Discord token: ${response.status} ${response.statusText}`, + ); + return { ...token, error: "RefreshTokenError" }; + } + + let refreshed: unknown; + try { + refreshed = await response.json(); + } catch { + logger.error("[auth] Discord returned non-JSON response during token refresh"); + return { ...token, error: "RefreshTokenError" }; + } + + // Validate response shape before using + const parsed = refreshed as Record; + if ( + typeof parsed?.access_token !== "string" || + typeof parsed?.expires_in !== "number" + ) { + logger.error("[auth] Discord refresh response missing required fields (access_token, expires_in)"); + return { ...token, error: "RefreshTokenError" }; + } + + return { + ...token, + accessToken: parsed.access_token, + accessTokenExpires: Date.now() + parsed.expires_in * 1000, + // Discord may rotate the refresh token + refreshToken: + typeof parsed.refresh_token === "string" + ? parsed.refresh_token + : token.refreshToken, + error: undefined, + }; +} + +export const authOptions: AuthOptions = { + providers: [ + DiscordProvider({ + clientId: process.env.DISCORD_CLIENT_ID!, + clientSecret: process.env.DISCORD_CLIENT_SECRET!, + authorization: { + params: { + scope: DISCORD_SCOPES, + }, + }, + }), + ], + callbacks: { + async jwt({ token, account }) { + // Security note: accessToken and refreshToken are stored in the JWT but + // are NOT exposed to client-side JavaScript because (1) the session + // callback below intentionally omits them — only user.id and error are + // forwarded, (2) NextAuth stores the JWT in an httpOnly, encrypted cookie + // that cannot be read by client JS. Server-side code can access these + // tokens via getToken() in API routes. + + // On initial sign-in, persist the Discord access token + if (account) { + token.accessToken = account.access_token; + token.refreshToken = account.refresh_token; + token.accessTokenExpires = account.expires_at + ? account.expires_at * 1000 + : Date.now() + 7 * 24 * 60 * 60 * 1000; // Default to 7 days if provider omits expires_at + token.id = account.providerAccountId; + } + + // If the access token has not expired, return it as-is. + // When expiresAt is undefined (e.g. JWT corruption or token migration), + // we intentionally fall through to refresh the token on every request + // rather than serving stale credentials — this is a safe default. + const expiresAt = token.accessTokenExpires as number | undefined; + if (expiresAt && Date.now() < expiresAt) { + return token; + } + + // Access token has expired — attempt refresh + if (token.refreshToken) { + return refreshDiscordToken(token as Record); + } + + // No refresh token available — cannot recover; flag as error + return { ...token, error: "RefreshTokenError" }; + }, + async session({ session, token }) { + // Only expose user ID to the client session. + // Intentionally NOT exposing token.accessToken or token.refreshToken to + // the client session — these stay in the server-side JWT. Use getToken() + // in API routes to access the Discord access token for server-side calls. + if (session.user) { + session.user.id = token.id as string; + } + // Propagate token refresh errors so the client can redirect to sign-in + if (token.error) { + session.error = token.error as string; + } + return session; + }, + }, + pages: { + signIn: "/login", + }, + session: { + strategy: "jwt", + maxAge: 7 * 24 * 60 * 60, // 7 days + }, + secret: process.env.NEXTAUTH_SECRET, +}; diff --git a/web/src/lib/discord.server.ts b/web/src/lib/discord.server.ts new file mode 100644 index 00000000..72e1023d --- /dev/null +++ b/web/src/lib/discord.server.ts @@ -0,0 +1,235 @@ +import "server-only"; + +import type { BotGuild, DiscordGuild, MutualGuild } from "@/types/discord"; +import { logger } from "@/lib/logger"; + +const DISCORD_API_BASE = "https://discord.com/api/v10"; + +/** Maximum number of retry attempts for rate-limited requests. */ +const MAX_RETRIES = 3; + +/** Discord returns at most 200 guilds per page. */ +const GUILDS_PER_PAGE = 200; + +/** + * Fetch wrapper with basic rate limit retry logic. + * When Discord returns 429 Too Many Requests, waits for the indicated + * retry-after duration and retries up to MAX_RETRIES times. + */ +export async function fetchWithRateLimit( + url: string, + init?: RequestInit, +): Promise { + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + const response = await fetch(url, init); + + if (response.status !== 429) { + return response; + } + + // Rate limited — parse retry-after header (seconds) + const retryAfter = response.headers.get("retry-after"); + const parsed = retryAfter ? Number.parseFloat(retryAfter) : NaN; + const waitMs = Number.isFinite(parsed) && parsed > 0 ? parsed * 1000 : 1000; + + if (attempt === MAX_RETRIES) { + return response; // Out of retries, return the 429 as-is + } + + logger.warn( + `[discord] Rate limited on ${url}, retrying in ${waitMs}ms (attempt ${attempt + 1}/${MAX_RETRIES})`, + ); + // Abort-aware sleep: if the caller's signal fires while we're waiting, + // cancel the delay immediately instead of blocking for the full duration. + const signal = init?.signal; + if (signal?.aborted) { + throw signal.reason; + } + await new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + reject(signal!.reason); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, waitMs); + signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + + // Should never reach here, but satisfies TypeScript + throw new Error("Unexpected end of rate limit retry loop"); +} + +/** + * Fetch ALL guilds a user belongs to from the Discord API. + * Uses cursor-based pagination with the `after` parameter to handle + * users in more than 200 guilds. + */ +export async function fetchUserGuilds( + accessToken: string, + signal?: AbortSignal, +): Promise { + const allGuilds: DiscordGuild[] = []; + let after: string | undefined; + + do { + const url = new URL(`${DISCORD_API_BASE}/users/@me/guilds`); + url.searchParams.set("limit", String(GUILDS_PER_PAGE)); + if (after) { + url.searchParams.set("after", after); + } + + // Note: Next.js skips the Data Cache for requests with Authorization + // headers when there's an uncached request above in the component tree, + // so `next: { revalidate }` is unreliable here. Use cache: 'no-store' + // to be explicit about always fetching fresh data. + const response = await fetchWithRateLimit(url.toString(), { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + signal, + cache: "no-store", + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch user guilds: ${response.status} ${response.statusText}`, + ); + } + + let data: unknown; + try { + data = await response.json(); + } catch { + throw new Error( + "Discord returned non-JSON response for user guilds", + ); + } + if (!Array.isArray(data)) { + throw new Error( + "Discord returned unexpected response shape for user guilds (expected array)", + ); + } + const page: DiscordGuild[] = data; + allGuilds.push(...page); + + // If we got fewer than the max, we've fetched everything + if (page.length < GUILDS_PER_PAGE) { + break; + } + + // Set cursor to the last guild's ID for the next page + after = page[page.length - 1].id; + } while (true); + + return allGuilds; +} + +/** + * Fetch guilds the bot is present in. + * This calls our own bot API to get the list of guilds. + * Requires BOT_API_SECRET env var for authentication. + */ +/** Result of fetchBotGuilds — discriminates API-unavailable from genuinely empty. */ +export interface BotGuildResult { + /** Whether the bot API was reachable and returned a valid response. */ + available: boolean; + guilds: BotGuild[]; +} + +export async function fetchBotGuilds(signal?: AbortSignal): Promise { + const botApiUrl = process.env.BOT_API_URL; + + if (!botApiUrl) { + logger.warn( + "[discord] BOT_API_URL is not set — cannot filter guilds by bot presence. " + + "Set BOT_API_URL to enable mutual guild filtering.", + ); + return { available: false, guilds: [] }; + } + + const botApiSecret = process.env.BOT_API_SECRET; + if (!botApiSecret) { + logger.warn( + "[discord] BOT_API_SECRET is missing while BOT_API_URL is set. " + + "Skipping bot guild fetch — refusing to send unauthenticated request.", + ); + return { available: false, guilds: [] }; + } + + try { + const response = await fetchWithRateLimit(`${botApiUrl}/api/guilds`, { + headers: { + Authorization: `Bearer ${botApiSecret}`, + }, + signal, + cache: "no-store", + }); + + if (!response.ok) { + logger.warn( + `[discord] Bot API returned ${response.status} ${response.statusText} — ` + + "continuing without bot guild filtering.", + ); + return { available: false, guilds: [] }; + } + + const data: unknown = await response.json(); + if (!Array.isArray(data)) { + logger.warn( + "[discord] Bot API returned unexpected response shape (expected array) — " + + "continuing without bot guild filtering.", + ); + return { available: false, guilds: [] as BotGuild[] }; + } + return { available: true, guilds: data as BotGuild[] }; + } catch (error) { + logger.warn( + "[discord] Bot API is unreachable — continuing without bot guild filtering.", + error, + ); + return { available: false, guilds: [] as BotGuild[] }; + } +} + +/** + * Get guilds where both the user and the bot are present. + * If bot guilds can't be determined (BOT_API_URL unset), returns all user + * guilds with botPresent=false so the UI can still be useful. + */ +export async function getMutualGuilds( + accessToken: string, + signal?: AbortSignal, +): Promise { + const [userGuilds, botResult] = await Promise.all([ + fetchUserGuilds(accessToken, signal), + // Defensive catch: even though fetchBotGuilds handles errors internally, + // wrap at the Promise.all level so an unexpected throw can never break + // the entire guild fetch — gracefully degrade to showing all user guilds. + fetchBotGuilds(signal).catch((err) => { + logger.warn("[discord] Unexpected error fetching bot guilds — degrading gracefully.", err); + return { available: false, guilds: [] } as BotGuildResult; + }), + ]); + + // If the bot API was unavailable, return all user guilds unfiltered so + // the UI can still be useful. If the API was available but the bot is + // genuinely in zero guilds, return an empty list. + if (!botResult.available) { + return userGuilds.map((guild) => ({ + ...guild, + botPresent: false as const, + })); + } + + const botGuildIds = new Set(botResult.guilds.map((g) => g.id)); + + return userGuilds + .filter((guild) => botGuildIds.has(guild.id)) + .map((guild) => ({ + ...guild, + botPresent: true as const, + })); +} diff --git a/web/src/lib/discord.ts b/web/src/lib/discord.ts new file mode 100644 index 00000000..26871bf9 --- /dev/null +++ b/web/src/lib/discord.ts @@ -0,0 +1,41 @@ +const DISCORD_CDN = "https://cdn.discordapp.com"; + +/** + * Minimal permissions the bot needs: + * - Kick Members (1 << 1) = 2 + * - Ban Members (1 << 2) = 4 + * - View Channels (1 << 10) = 1,024 + * - Send Messages (1 << 11) = 2,048 + * - Manage Messages (1 << 13) = 8,192 + * - Read Msg History (1 << 16) = 65,536 + * - Moderate Members (1 << 40) = 1,099,511,627,776 + * Total = 1,099,511,704,582 + * + * Verified: (1n<<1n)|(1n<<2n)|(1n<<10n)|(1n<<11n)|(1n<<13n)|(1n<<16n)|(1n<<40n) === 1099511704582n + */ +const BOT_PERMISSIONS = "1099511704582"; + +/** + * Build the bot OAuth2 invite URL, or return null when + * NEXT_PUBLIC_DISCORD_CLIENT_ID is not configured. + */ +export function getBotInviteUrl(): string | null { + const clientId = process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID; + if (!clientId) return null; + return `https://discord.com/api/oauth2/authorize?client_id=${clientId}&permissions=${BOT_PERMISSIONS}&scope=bot%20applications.commands`; +} + +/** + * Get the URL for a guild's icon, or null if the guild has no custom icon. + * Discord doesn't provide default guild icons via CDN — callers should + * show the guild's initials or a placeholder icon when this returns null. + */ +export function getGuildIconUrl( + guildId: string, + iconHash: string | null, + size = 128, +): string | null { + if (!iconHash) return null; + const ext = iconHash.startsWith("a_") ? "gif" : "webp"; + return `${DISCORD_CDN}/icons/${guildId}/${iconHash}.${ext}?size=${size}`; +} diff --git a/web/src/lib/logger.ts b/web/src/lib/logger.ts new file mode 100644 index 00000000..9add9ce0 --- /dev/null +++ b/web/src/lib/logger.ts @@ -0,0 +1,24 @@ +// ⚠️ INTENTIONAL console.* usage — do NOT flag as a lint violation. +// +// AGENTS.md and Biome rules ban console.* in the main bot codebase (src/), +// but this file is part of the **web dashboard** package (web/). The web +// dashboard intentionally wraps console methods behind a thin logger +// abstraction so every call-site can be migrated to a structured provider +// (e.g. pino, winston) later without a mass find-and-replace. The +// eslint-disable below is deliberate for the same reason. + +/** + * Simple logger utility for the web dashboard. + * + * Wraps console methods so logging can be swapped to a structured provider + * (e.g. pino, winston) later without touching every call-site. + */ + +/* eslint-disable no-console */ + +export const logger = { + debug: (...args: unknown[]) => console.debug(...args), + info: (...args: unknown[]) => console.info(...args), + warn: (...args: unknown[]) => console.warn(...args), + error: (...args: unknown[]) => console.error(...args), +}; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts new file mode 100644 index 00000000..365058ce --- /dev/null +++ b/web/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/web/src/proxy.ts b/web/src/proxy.ts new file mode 100644 index 00000000..4d059151 --- /dev/null +++ b/web/src/proxy.ts @@ -0,0 +1,28 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { getToken } from "next-auth/jwt"; + +/** + * Route protection middleware. + * + * Compatibility note: This file uses the Next.js 16 `proxy` export convention + * (renamed from `middleware`). NextAuth v4 relies on standard Next.js middleware + * patterns and is installed with --legacy-peer-deps for Next.js 16 compatibility. + * The proxy export works correctly as middleware for route protection. + * + * @see https://nextjs.org/docs/app/api-reference/file-conventions/proxy + */ +export async function proxy(request: NextRequest) { + const token = await getToken({ req: request }); + + if (!token || token.error === "RefreshTokenError") { + const loginUrl = new URL("/login", request.url); + loginUrl.searchParams.set("callbackUrl", request.nextUrl.pathname + request.nextUrl.search); + return NextResponse.redirect(loginUrl); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/dashboard/:path*"], +}; diff --git a/web/src/types/discord.ts b/web/src/types/discord.ts new file mode 100644 index 00000000..db769a19 --- /dev/null +++ b/web/src/types/discord.ts @@ -0,0 +1,18 @@ +export interface DiscordGuild { + id: string; + name: string; + icon: string | null; + owner: boolean; + permissions: string; + features: string[]; +} + +export interface BotGuild { + id: string; + name: string; + icon: string | null; +} + +export interface MutualGuild extends DiscordGuild { + botPresent: boolean; +} diff --git a/web/src/types/next-auth.d.ts b/web/src/types/next-auth.d.ts new file mode 100644 index 00000000..32369121 --- /dev/null +++ b/web/src/types/next-auth.d.ts @@ -0,0 +1,21 @@ +import type { DefaultSession } from "next-auth"; + +declare module "next-auth" { + interface Session { + /** Propagated from JWT when token refresh fails */ + error?: string; + user: { + id: string; + } & DefaultSession["user"]; + } +} + +declare module "next-auth/jwt" { + interface JWT { + accessToken?: string; + refreshToken?: string; + accessTokenExpires?: number; + id?: string; + error?: string; + } +} diff --git a/web/tests/__mocks__/server-only.ts b/web/tests/__mocks__/server-only.ts new file mode 100644 index 00000000..a5540350 --- /dev/null +++ b/web/tests/__mocks__/server-only.ts @@ -0,0 +1,2 @@ +// Mock for "server-only" package — allows importing server modules in tests. +export {}; diff --git a/web/tests/api/guilds.test.ts b/web/tests/api/guilds.test.ts new file mode 100644 index 00000000..71b5401f --- /dev/null +++ b/web/tests/api/guilds.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; + +// Mock next-auth/providers/discord +vi.mock("next-auth/providers/discord", () => ({ + default: vi.fn((config: Record) => ({ + id: "discord", + name: "Discord", + type: "oauth", + ...config, + })), +})); + +// Mock getToken from next-auth/jwt (used in the new API route) +const mockGetToken = vi.fn(); +vi.mock("next-auth/jwt", () => ({ + getToken: (...args: unknown[]) => mockGetToken(...args), +})); + +// Mock discord server lib +const mockGetMutualGuilds = vi.fn(); +vi.mock("@/lib/discord.server", () => ({ + getMutualGuilds: (...args: unknown[]) => mockGetMutualGuilds(...args), +})); + +import { GET } from "@/app/api/guilds/route"; + +function createMockRequest(url = "http://localhost:3000/api/guilds"): NextRequest { + return new NextRequest(new URL(url)); +} + +describe("GET /api/guilds", () => { + const originalSecret = process.env.NEXTAUTH_SECRET; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.NEXTAUTH_SECRET = "a-valid-secret-that-is-at-least-32-characters-long"; + }); + + afterEach(() => { + if (originalSecret === undefined) { + delete process.env.NEXTAUTH_SECRET; + } else { + process.env.NEXTAUTH_SECRET = originalSecret; + } + }); + + it("returns 401 when no token exists", async () => { + mockGetToken.mockResolvedValue(null); + + const response = await GET(createMockRequest()); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 401 when token has no access token", async () => { + mockGetToken.mockResolvedValue({ + sub: "123", + id: "user-123", + // No accessToken + }); + + const response = await GET(createMockRequest()); + + expect(response.status).toBe(401); + }); + + it("returns guilds when authenticated with valid token", async () => { + const mockGuilds = [ + { id: "1", name: "Server 1", icon: null, botPresent: true }, + ]; + + mockGetToken.mockResolvedValue({ + sub: "123", + accessToken: "valid-discord-token", + refreshToken: "refresh-token", + accessTokenExpires: Date.now() + 60_000, + id: "discord-user-123", + }); + mockGetMutualGuilds.mockResolvedValue(mockGuilds); + + const response = await GET(createMockRequest()); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual(mockGuilds); + expect(mockGetMutualGuilds).toHaveBeenCalledWith( + "valid-discord-token", + expect.any(AbortSignal), + ); + }); + + it("returns 401 when token has RefreshTokenError", async () => { + mockGetToken.mockResolvedValue({ + sub: "123", + accessToken: "stale-token", + id: "discord-user-123", + error: "RefreshTokenError", + }); + + const response = await GET(createMockRequest()); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toMatch(/sign in/i); + expect(mockGetMutualGuilds).not.toHaveBeenCalled(); + }); + + it("returns 500 on discord API error", async () => { + mockGetToken.mockResolvedValue({ + sub: "123", + accessToken: "valid-discord-token", + refreshToken: "refresh-token", + accessTokenExpires: Date.now() + 60_000, + id: "discord-user-123", + }); + mockGetMutualGuilds.mockRejectedValue(new Error("Discord API error")); + + const response = await GET(createMockRequest()); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.error).toBe("Failed to fetch guilds"); + }); +}); diff --git a/web/tests/api/health.test.ts b/web/tests/api/health.test.ts new file mode 100644 index 00000000..02dd16ed --- /dev/null +++ b/web/tests/api/health.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from "vitest"; +import { GET } from "@/app/api/health/route"; + +describe("GET /api/health", () => { + it("returns 200 with status ok", async () => { + const response = await GET(); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.status).toBe("ok"); + expect(typeof body.timestamp).toBe("string"); + }); +}); diff --git a/web/tests/app/dashboard.test.tsx b/web/tests/app/dashboard.test.tsx new file mode 100644 index 00000000..29ab0e86 --- /dev/null +++ b/web/tests/app/dashboard.test.tsx @@ -0,0 +1,85 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; + +// Use vi.hoisted so mocks are available inside hoisted vi.mock factories +const { mockGetServerSession, mockRedirect } = vi.hoisted(() => ({ + mockGetServerSession: vi.fn(), + mockRedirect: vi.fn(), +})); + +vi.mock("next-auth", () => ({ + getServerSession: mockGetServerSession, +})); + +vi.mock("@/lib/auth", () => ({ + authOptions: {}, +})); + +vi.mock("next/navigation", () => ({ + redirect: mockRedirect, + usePathname: () => "/dashboard", +})); + +vi.mock("@/components/layout/dashboard-shell", () => ({ + DashboardShell: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +import DashboardPage from "@/app/dashboard/page"; +import DashboardLayout from "@/app/dashboard/layout"; + +describe("DashboardPage", () => { + it("renders the dashboard heading", () => { + render(); + expect(screen.getByText("Dashboard")).toBeInTheDocument(); + expect( + screen.getByText("Overview of your Bill Bot server."), + ).toBeInTheDocument(); + }); + + it("renders stat cards", () => { + render(); + expect(screen.getByText("Total server members")).toBeInTheDocument(); + expect(screen.getByText("Total moderation actions")).toBeInTheDocument(); + expect(screen.getByText("AI messages this week")).toBeInTheDocument(); + expect(screen.getByText("Bot uptime")).toBeInTheDocument(); + }); + + it("renders getting started card", () => { + render(); + expect(screen.getByText("Getting Started")).toBeInTheDocument(); + }); +}); + +describe("DashboardLayout", () => { + it("wraps children in DashboardShell when authenticated", async () => { + mockGetServerSession.mockResolvedValue({ + user: { id: "123", name: "Test" }, + }); + + const result = await DashboardLayout({ + children:
Child
, + }); + render(result); + expect(screen.getByTestId("dashboard-shell")).toBeInTheDocument(); + expect(screen.getByTestId("child")).toBeInTheDocument(); + }); + + it("redirects to /login when not authenticated", async () => { + mockGetServerSession.mockResolvedValue(null); + + // redirect() throws in Next.js to halt rendering — simulate that + mockRedirect.mockImplementation((url: string) => { + throw new Error(`NEXT_REDIRECT:${url}`); + }); + + await expect( + DashboardLayout({ + children:
Child
, + }), + ).rejects.toThrow("NEXT_REDIRECT:/login"); + expect(mockRedirect).toHaveBeenCalledWith("/login"); + }); +}); diff --git a/web/tests/app/landing.test.tsx b/web/tests/app/landing.test.tsx new file mode 100644 index 00000000..84ac661d --- /dev/null +++ b/web/tests/app/landing.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import LandingPage from "@/app/page"; + +describe("LandingPage", () => { + const originalClientId = process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID; + + afterEach(() => { + // Restore env var to prevent pollution between tests + if (originalClientId !== undefined) { + process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID = originalClientId; + } else { + delete process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID; + } + }); + + it("renders the hero heading", () => { + render(); + const heading = screen.getByRole("heading", { level: 1 }); + expect(heading).toHaveTextContent("Bill Bot"); + }); + + it("renders feature cards", () => { + render(); + expect(screen.getByText("AI Chat")).toBeInTheDocument(); + expect(screen.getByText("Moderation")).toBeInTheDocument(); + expect(screen.getByText("Welcome Messages")).toBeInTheDocument(); + expect(screen.getByText("Spam Detection")).toBeInTheDocument(); + expect(screen.getByText("Runtime Config")).toBeInTheDocument(); + expect(screen.getByText("Web Dashboard")).toBeInTheDocument(); + }); + + it("renders sign in button", () => { + render(); + expect(screen.getByText("Sign In")).toBeInTheDocument(); + }); + + it("hides Add to Server button when CLIENT_ID is not set", () => { + delete process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID; + render(); + expect(screen.queryByText("Add to Server")).not.toBeInTheDocument(); + }); + + it("shows Add to Server buttons when CLIENT_ID is set", () => { + process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID = "test-client-id"; + render(); + expect(screen.getAllByText("Add to Server").length).toBeGreaterThan(0); + }); + + it("renders footer with links", () => { + render(); + expect(screen.getByText("GitHub")).toBeInTheDocument(); + expect(screen.getByText("Discord")).toBeInTheDocument(); + }); + + it("has CTA section", () => { + render(); + expect(screen.getByText("Ready to get started?")).toBeInTheDocument(); + }); +}); diff --git a/web/tests/app/login.test.tsx b/web/tests/app/login.test.tsx new file mode 100644 index 00000000..62d25dea --- /dev/null +++ b/web/tests/app/login.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// Mock next-auth/react +const mockSignIn = vi.fn(); +const mockSignOut = vi.fn(); +let mockSession: { data: unknown; status: string } = { data: null, status: "unauthenticated" }; +vi.mock("next-auth/react", () => ({ + useSession: () => mockSession, + signIn: (...args: unknown[]) => mockSignIn(...args), + signOut: (...args: unknown[]) => mockSignOut(...args), +})); + +// Mock next/navigation +let mockSearchParams = new URLSearchParams(); +const mockPush = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockPush }), + useSearchParams: () => mockSearchParams, +})); + +import LoginPage from "@/app/login/page"; + +describe("LoginPage", () => { + beforeEach(() => { + mockSearchParams = new URLSearchParams(); + mockSignIn.mockClear(); + mockSignOut.mockClear(); + mockPush.mockClear(); + mockSession = { data: null, status: "unauthenticated" }; + }); + + it("renders the sign-in card", async () => { + render(); + await waitFor(() => { + expect(screen.getByText("Welcome to Bill Bot")).toBeInTheDocument(); + }); + expect(screen.getByText("Sign in with Discord")).toBeInTheDocument(); + }); + + it("calls signIn with /dashboard when no callbackUrl param", async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText("Sign in with Discord")).toBeInTheDocument(); + }); + await user.click(screen.getByText("Sign in with Discord")); + expect(mockSignIn).toHaveBeenCalledWith("discord", { + callbackUrl: "/dashboard", + }); + }); + + it("calls signIn with callbackUrl from search params", async () => { + const user = userEvent.setup(); + mockSearchParams = new URLSearchParams("callbackUrl=/servers/123"); + render(); + await waitFor(() => { + expect(screen.getByText("Sign in with Discord")).toBeInTheDocument(); + }); + await user.click(screen.getByText("Sign in with Discord")); + expect(mockSignIn).toHaveBeenCalledWith("discord", { + callbackUrl: "/servers/123", + }); + }); + + it("shows privacy note", async () => { + render(); + await waitFor(() => { + expect( + screen.getByText( + "We'll only access your Discord profile and server list.", + ), + ).toBeInTheDocument(); + }); + }); + + it("shows login form without calling signOut on RefreshTokenError", async () => { + mockSession = { + data: { user: { name: "Test" }, error: "RefreshTokenError" }, + status: "authenticated", + }; + render(); + await waitFor(() => { + expect(screen.getByText("Sign in with Discord")).toBeInTheDocument(); + }); + // Should NOT redirect to dashboard + expect(mockPush).not.toHaveBeenCalled(); + // LoginForm no longer calls signOut — Header handles it centrally + expect(mockSignOut).not.toHaveBeenCalled(); + // Should show the login form (not the loading spinner) + expect(screen.getByText("Welcome to Bill Bot")).toBeInTheDocument(); + }); + + it("redirects authenticated users instead of showing login form", async () => { + mockSession = { + data: { user: { name: "Test", email: "test@test.com" } }, + status: "authenticated", + }; + render(); + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith("/dashboard"); + }); + // Should show loading state, not the login form + expect(screen.queryByText("Welcome to Bill Bot")).not.toBeInTheDocument(); + }); +}); diff --git a/web/tests/components/layout/dashboard-shell.test.tsx b/web/tests/components/layout/dashboard-shell.test.tsx new file mode 100644 index 00000000..5c858caf --- /dev/null +++ b/web/tests/components/layout/dashboard-shell.test.tsx @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; + +// Mock child components — DashboardShell is now a server component +vi.mock("@/components/layout/header", () => ({ + Header: () =>
Header
, +})); + +vi.mock("@/components/layout/sidebar", () => ({ + Sidebar: () => , +})); + +vi.mock("@/components/layout/server-selector", () => ({ + ServerSelector: () =>
Servers
, +})); + +import { DashboardShell } from "@/components/layout/dashboard-shell"; + +describe("DashboardShell", () => { + it("renders header, sidebar, and content", () => { + render( + +
Content
+
, + ); + expect(screen.getByTestId("header")).toBeInTheDocument(); + expect(screen.getByTestId("sidebar")).toBeInTheDocument(); + expect(screen.getByTestId("content")).toBeInTheDocument(); + }); + + it("renders server selector in desktop sidebar", () => { + render( + +
Content
+
, + ); + expect(screen.getByTestId("server-selector")).toBeInTheDocument(); + }); +}); diff --git a/web/tests/components/layout/header.test.tsx b/web/tests/components/layout/header.test.tsx new file mode 100644 index 00000000..c447b3dc --- /dev/null +++ b/web/tests/components/layout/header.test.tsx @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// Hoist mock variables so they can be mutated per-test +const mockUseSession = vi.fn<() => { data: unknown; status: string }>(); +const mockSignOut = vi.fn(); + +// Mock next-auth/react +vi.mock("next-auth/react", () => ({ + useSession: () => mockUseSession(), + signOut: (...args: unknown[]) => mockSignOut(...args), +})); + +// Mock the MobileSidebar client component +vi.mock("@/components/layout/mobile-sidebar", () => ({ + MobileSidebar: () => ( + + ), +})); + +import { Header } from "@/components/layout/header"; + +const authenticatedSession = { + data: { + user: { + id: "discord-user-123", + name: "TestUser", + email: "test@example.com", + image: "https://cdn.discordapp.com/avatars/123/abc.png", + }, + }, + status: "authenticated", +}; + +describe("Header", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseSession.mockReturnValue(authenticatedSession); + }); + + it("renders the brand name", () => { + render(
); + expect(screen.getByText("Bill Bot Dashboard")).toBeInTheDocument(); + }); + + it("renders the mobile sidebar toggle", () => { + render(
); + expect(screen.getByTestId("mobile-sidebar-toggle")).toBeInTheDocument(); + }); + + it("renders user fallback avatar when authenticated", () => { + render(
); + // Radix Avatar shows fallback initially in jsdom + expect(screen.getByText("T")).toBeInTheDocument(); + }); + + describe("loading state", () => { + it("renders a loading skeleton when session is loading", () => { + mockUseSession.mockReturnValue({ data: null, status: "loading" }); + render(
); + expect(screen.getByTestId("header-skeleton")).toBeInTheDocument(); + // No user dropdown should appear + expect(screen.queryByText("T")).not.toBeInTheDocument(); + expect(screen.queryByText("TestUser")).not.toBeInTheDocument(); + }); + }); + + describe("unauthenticated state", () => { + it("renders a sign-in link when unauthenticated", () => { + mockUseSession.mockReturnValue({ data: null, status: "unauthenticated" }); + render(
); + const signInLink = screen.getByRole("link", { name: "Sign in" }); + expect(signInLink).toBeInTheDocument(); + expect(signInLink).toHaveAttribute("href", "/login"); + // User-specific elements should not be present + expect(screen.queryByText("T")).not.toBeInTheDocument(); + }); + }); + + describe("RefreshTokenError", () => { + it("calls signOut when session has RefreshTokenError", () => { + mockUseSession.mockReturnValue({ + data: { + user: { id: "123", name: "TestUser" }, + error: "RefreshTokenError", + }, + status: "authenticated", + }); + + render(
); + + expect(mockSignOut).toHaveBeenCalledWith({ callbackUrl: "/login" }); + }); + + it("does not call signOut when session has no error", () => { + render(
); + expect(mockSignOut).not.toHaveBeenCalled(); + }); + }); + + describe("user dropdown interactions", () => { + it("opens dropdown menu when avatar is clicked", async () => { + const user = userEvent.setup(); + render(
); + + // The avatar button's accessible name comes from the AvatarFallback text "T" + const avatarButton = screen.getByRole("button", { name: "T" }); + await user.click(avatarButton); + + // Dropdown content should now be visible + await waitFor(() => { + expect(screen.getByText("TestUser")).toBeInTheDocument(); + }); + expect(screen.getByText("Documentation")).toBeInTheDocument(); + expect(screen.getByText("Sign out")).toBeInTheDocument(); + }); + + it("calls signOut when sign-out button is clicked", async () => { + const user = userEvent.setup(); + render(
); + + // Open dropdown + const avatarButton = screen.getByRole("button", { name: "T" }); + await user.click(avatarButton); + + // Wait for dropdown to open, then click sign out + await waitFor(() => { + expect(screen.getByText("Sign out")).toBeInTheDocument(); + }); + await user.click(screen.getByText("Sign out")); + + expect(mockSignOut).toHaveBeenCalledWith({ callbackUrl: "/" }); + }); + }); +}); diff --git a/web/tests/components/layout/server-selector.test.tsx b/web/tests/components/layout/server-selector.test.tsx new file mode 100644 index 00000000..d07f384f --- /dev/null +++ b/web/tests/components/layout/server-selector.test.tsx @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// Mock next/image +vi.mock("next/image", () => ({ + default: ({ alt, ...props }: { alt: string; [key: string]: unknown }) => ( + {alt} + ), +})); + +import { ServerSelector } from "@/components/layout/server-selector"; + +describe("ServerSelector", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(global, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it("shows loading state initially", () => { + fetchSpy.mockReturnValue(new Promise(() => {})); // never resolves + render(); + expect(screen.getByText("Loading servers...")).toBeInTheDocument(); + }); + + it("shows no mutual servers message when empty", async () => { + fetchSpy.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + } as Response); + render(); + await waitFor(() => { + expect(screen.getByText("No mutual servers")).toBeInTheDocument(); + expect( + screen.getByText(/Bill Bot isn't in any of your Discord servers/), + ).toBeInTheDocument(); + }); + }); + + it("renders guild name when guilds are returned", async () => { + const guilds = [ + { + id: "1", + name: "Test Server", + icon: null, + owner: true, + permissions: "8", + features: [], + botPresent: true, + }, + ]; + fetchSpy.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(guilds), + } as Response); + render(); + await waitFor(() => { + expect(screen.getByText("Test Server")).toBeInTheDocument(); + }); + }); + + it("shows error state with retry button on fetch failure", async () => { + fetchSpy.mockRejectedValue(new Error("Network error")); + render(); + await waitFor(() => { + expect(screen.getByText("Failed to load servers")).toBeInTheDocument(); + expect(screen.getByText("Retry")).toBeInTheDocument(); + }); + }); + + it("shows error state on non-OK response", async () => { + fetchSpy.mockResolvedValue({ + ok: false, + status: 500, + } as Response); + render(); + await waitFor(() => { + expect(screen.getByText("Failed to load servers")).toBeInTheDocument(); + }); + }); + + it("re-fetches guilds when retry button is clicked", async () => { + const user = userEvent.setup(); + + // First call fails + fetchSpy.mockRejectedValueOnce(new Error("Network error")); + + render(); + await waitFor(() => { + expect(screen.getByText("Retry")).toBeInTheDocument(); + }); + + // Second call succeeds + const guilds = [ + { + id: "1", + name: "Recovered Server", + icon: null, + owner: true, + permissions: "8", + features: [], + botPresent: true, + }, + ]; + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(guilds), + } as Response); + + await user.click(screen.getByText("Retry")); + + await waitFor(() => { + expect(screen.getByText("Recovered Server")).toBeInTheDocument(); + }); + // Initial call + retry call + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/web/tests/components/layout/sidebar.test.tsx b/web/tests/components/layout/sidebar.test.tsx new file mode 100644 index 00000000..a1704769 --- /dev/null +++ b/web/tests/components/layout/sidebar.test.tsx @@ -0,0 +1,37 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// Mock next/navigation +vi.mock("next/navigation", () => ({ + usePathname: () => "/dashboard", +})); + +import { Sidebar } from "@/components/layout/sidebar"; + +describe("Sidebar", () => { + it("renders navigation links", () => { + render(); + expect(screen.getByText("Overview")).toBeInTheDocument(); + expect(screen.getByText("Moderation")).toBeInTheDocument(); + expect(screen.getByText("AI Chat")).toBeInTheDocument(); + expect(screen.getByText("Members")).toBeInTheDocument(); + expect(screen.getByText("Bot Config")).toBeInTheDocument(); + expect(screen.getByText("Settings")).toBeInTheDocument(); + }); + + it("highlights active route", () => { + render(); + const overviewLink = screen.getByText("Overview").closest("a"); + expect(overviewLink).not.toBeNull(); + expect(overviewLink?.className).toContain("bg-accent"); + }); + + it("calls onNavClick when a link is clicked", async () => { + const user = userEvent.setup(); + const onNavClick = vi.fn(); + render(); + await user.click(screen.getByText("Moderation")); + expect(onNavClick).toHaveBeenCalled(); + }); +}); diff --git a/web/tests/components/providers.test.tsx b/web/tests/components/providers.test.tsx new file mode 100644 index 00000000..29bba6a4 --- /dev/null +++ b/web/tests/components/providers.test.tsx @@ -0,0 +1,25 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; + +// Mock next-auth/react +vi.mock("next-auth/react", () => ({ + SessionProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + useSession: () => ({ data: null, status: "unauthenticated" }), + signIn: vi.fn(), +})); + +import { Providers } from "@/components/providers"; + +describe("Providers", () => { + it("wraps children in SessionProvider", () => { + render( + +
Hello
+
, + ); + expect(screen.getByTestId("session-provider")).toBeDefined(); + expect(screen.getByTestId("child")).toBeDefined(); + }); +}); diff --git a/web/tests/lib/auth.test.ts b/web/tests/lib/auth.test.ts new file mode 100644 index 00000000..ce99b628 --- /dev/null +++ b/web/tests/lib/auth.test.ts @@ -0,0 +1,301 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock the next-auth/providers/discord module +vi.mock("next-auth/providers/discord", () => ({ + default: vi.fn((config: Record) => ({ + id: "discord", + name: "Discord", + type: "oauth", + ...config, + })), +})); + +describe("authOptions", () => { + beforeEach(() => { + vi.resetModules(); + process.env.DISCORD_CLIENT_ID = "test-client-id"; + process.env.DISCORD_CLIENT_SECRET = "test-client-secret"; + process.env.NEXTAUTH_SECRET = "a-valid-secret-that-is-at-least-32-characters-long"; + }); + + it("has discord provider configured", async () => { + const { authOptions } = await import("@/lib/auth"); + expect(authOptions.providers).toHaveLength(1); + expect(authOptions.providers[0]).toMatchObject({ + id: "discord", + }); + }); + + it("uses JWT session strategy", async () => { + const { authOptions } = await import("@/lib/auth"); + expect(authOptions.session?.strategy).toBe("jwt"); + }); + + it("sets custom sign-in page to /login", async () => { + const { authOptions } = await import("@/lib/auth"); + expect(authOptions.pages?.signIn).toBe("/login"); + }); + + it("sets session max age to 7 days", async () => { + const { authOptions } = await import("@/lib/auth"); + expect(authOptions.session?.maxAge).toBe(7 * 24 * 60 * 60); + }); + + it("jwt callback persists access token on sign-in", async () => { + const { authOptions } = await import("@/lib/auth"); + const jwtCallback = authOptions.callbacks?.jwt; + expect(jwtCallback).toBeDefined(); + if (!jwtCallback) return; + + const result = await jwtCallback({ + token: { sub: "123" }, + account: { + access_token: "discord-access-token", + refresh_token: "discord-refresh-token", + expires_at: 1700000000, + provider: "discord", + type: "oauth", + providerAccountId: "discord-user-123", + token_type: "Bearer", + }, + user: { id: "123", name: "Test", email: "test@test.com" }, + trigger: "signIn", + } as Parameters>[0]); + + expect(result.accessToken).toBe("discord-access-token"); + expect(result.refreshToken).toBe("discord-refresh-token"); + expect(result.id).toBe("discord-user-123"); + }); + + it("jwt callback returns existing token when no account", async () => { + const { authOptions } = await import("@/lib/auth"); + const jwtCallback = authOptions.callbacks?.jwt; + expect(jwtCallback).toBeDefined(); + if (!jwtCallback) return; + + const existingToken = { + sub: "123", + accessToken: "existing-token", + accessTokenExpires: Date.now() + 60_000, // not expired + id: "user-123", + }; + + const result = await jwtCallback({ + token: existingToken, + user: { id: "123", name: "Test", email: "test@test.com" }, + trigger: "update", + } as Parameters>[0]); + + expect(result.accessToken).toBe("existing-token"); + expect(result.id).toBe("user-123"); + }); + + it("session callback exposes user id but NOT access token", async () => { + const { authOptions } = await import("@/lib/auth"); + const sessionCallback = authOptions.callbacks?.session; + expect(sessionCallback).toBeDefined(); + if (!sessionCallback) return; + + const result = await sessionCallback({ + session: { + user: { name: "Test", email: "test@test.com", image: null }, + expires: "2099-01-01", + }, + token: { + sub: "123", + accessToken: "discord-access-token", + id: "discord-user-123", + }, + } as Parameters>[0]); + + // Access token should NOT be exposed to client session + expect((result as unknown as Record).accessToken).toBeUndefined(); + // User id should be exposed + expect((result as unknown as { user: { id: string } }).user.id).toBe("discord-user-123"); + }); + + it("session callback propagates RefreshTokenError", async () => { + const { authOptions } = await import("@/lib/auth"); + const sessionCallback = authOptions.callbacks?.session; + expect(sessionCallback).toBeDefined(); + if (!sessionCallback) return; + + const result = await sessionCallback({ + session: { + user: { name: "Test", email: "test@test.com", image: null }, + expires: "2099-01-01", + }, + token: { + sub: "123", + id: "discord-user-123", + error: "RefreshTokenError", + }, + } as Parameters>[0]); + + expect((result as unknown as Record).error).toBe("RefreshTokenError"); + }); + + it("rejects default NEXTAUTH_SECRET placeholder", async () => { + vi.resetModules(); + process.env.NEXTAUTH_SECRET = "change-me-in-production"; + await expect(import("@/lib/auth")).rejects.toThrow("NEXTAUTH_SECRET"); + }); + + it("rejects NEXTAUTH_SECRET shorter than 32 chars", async () => { + vi.resetModules(); + process.env.NEXTAUTH_SECRET = "too-short"; + await expect(import("@/lib/auth")).rejects.toThrow("NEXTAUTH_SECRET"); + }); + + it("rejects the new CHANGE_ME placeholder in NEXTAUTH_SECRET", async () => { + vi.resetModules(); + process.env.NEXTAUTH_SECRET = "CHANGE_ME_generate_with_openssl_rand_base64_32"; + await expect(import("@/lib/auth")).rejects.toThrow("NEXTAUTH_SECRET"); + }); + + it("rejects missing DISCORD_CLIENT_ID", async () => { + vi.resetModules(); + delete process.env.DISCORD_CLIENT_ID; + process.env.DISCORD_CLIENT_SECRET = "test-client-secret"; + process.env.NEXTAUTH_SECRET = "a-valid-secret-that-is-at-least-32-characters-long"; + await expect(import("@/lib/auth")).rejects.toThrow("DISCORD_CLIENT_ID"); + }); + + it("rejects missing DISCORD_CLIENT_SECRET", async () => { + vi.resetModules(); + process.env.DISCORD_CLIENT_ID = "test-client-id"; + delete process.env.DISCORD_CLIENT_SECRET; + process.env.NEXTAUTH_SECRET = "a-valid-secret-that-is-at-least-32-characters-long"; + await expect(import("@/lib/auth")).rejects.toThrow("DISCORD_CLIENT_SECRET"); + }); +}); + +describe("refreshDiscordToken", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + vi.resetModules(); + process.env.DISCORD_CLIENT_ID = "test-client-id"; + process.env.DISCORD_CLIENT_SECRET = "test-client-secret"; + process.env.NEXTAUTH_SECRET = "a-valid-secret-that-is-at-least-32-characters-long"; + fetchSpy = vi.spyOn(global, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it("returns refreshed token on success", async () => { + fetchSpy.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + access_token: "new-access-token", + expires_in: 604800, + refresh_token: "new-refresh-token", + }), + } as Response); + + const { refreshDiscordToken } = await import("@/lib/auth"); + const result = await refreshDiscordToken({ + accessToken: "old-token", + refreshToken: "old-refresh", + }); + + expect(result.accessToken).toBe("new-access-token"); + expect(result.refreshToken).toBe("new-refresh-token"); + expect(result.error).toBeUndefined(); + expect(typeof result.accessTokenExpires).toBe("number"); + }); + + it("returns RefreshTokenError on failure", async () => { + fetchSpy.mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + } as Response); + + const { refreshDiscordToken } = await import("@/lib/auth"); + const result = await refreshDiscordToken({ + accessToken: "old-token", + refreshToken: "old-refresh", + }); + + expect(result.error).toBe("RefreshTokenError"); + expect(result.accessToken).toBe("old-token"); + }); + + it("handles token rotation — keeps original refresh token if not rotated", async () => { + fetchSpy.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + access_token: "new-access-token", + expires_in: 604800, + // No refresh_token in response — Discord didn't rotate + }), + } as Response); + + const { refreshDiscordToken } = await import("@/lib/auth"); + const result = await refreshDiscordToken({ + accessToken: "old-token", + refreshToken: "original-refresh-token", + }); + + expect(result.accessToken).toBe("new-access-token"); + expect(result.refreshToken).toBe("original-refresh-token"); + }); + + it("returns RefreshTokenError on network failure", async () => { + fetchSpy.mockRejectedValue(new TypeError("fetch failed")); + + const { refreshDiscordToken } = await import("@/lib/auth"); + const result = await refreshDiscordToken({ + accessToken: "old-token", + refreshToken: "old-refresh", + }); + + expect(result.error).toBe("RefreshTokenError"); + expect(result.accessToken).toBe("old-token"); + }); + + it("returns RefreshTokenError when Discord returns non-JSON response", async () => { + fetchSpy.mockResolvedValue({ + ok: true, + json: () => Promise.reject(new SyntaxError("Unexpected token <")), + } as unknown as Response); + + const { refreshDiscordToken } = await import("@/lib/auth"); + const result = await refreshDiscordToken({ + accessToken: "old-token", + refreshToken: "old-refresh", + }); + + expect(result.error).toBe("RefreshTokenError"); + expect(result.accessToken).toBe("old-token"); + }); + + it("jwt callback skips refresh when no refresh token exists", async () => { + const { authOptions } = await import("@/lib/auth"); + const jwtCallback = authOptions.callbacks?.jwt; + expect(jwtCallback).toBeDefined(); + if (!jwtCallback) return; + + const expiredToken = { + sub: "123", + accessToken: "expired-token", + accessTokenExpires: Date.now() - 60_000, // expired + id: "user-123", + // No refreshToken + }; + + const result = await jwtCallback({ + token: expiredToken, + user: { id: "123", name: "Test", email: "test@test.com" }, + trigger: "update", + } as Parameters>[0]); + + // Should return the token as-is without attempting refresh + expect(result.accessToken).toBe("expired-token"); + }); +}); diff --git a/web/tests/lib/discord.test.ts b/web/tests/lib/discord.test.ts new file mode 100644 index 00000000..2b19c66c --- /dev/null +++ b/web/tests/lib/discord.test.ts @@ -0,0 +1,533 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { getGuildIconUrl } from "@/lib/discord"; +import { + fetchUserGuilds, + fetchBotGuilds, + getMutualGuilds, + fetchWithRateLimit, +} from "@/lib/discord.server"; + +describe("getGuildIconUrl", () => { + it("returns null when no icon hash is provided", () => { + const url = getGuildIconUrl("123", null); + expect(url).toBeNull(); + }); + + it("returns null for all guilds without an icon hash", () => { + const url0 = getGuildIconUrl("0", null); + const url1 = getGuildIconUrl("1", null); + const url4 = getGuildIconUrl("4", null); + expect(url0).toBeNull(); + expect(url1).toBeNull(); + expect(url4).toBeNull(); + }); + + it("returns webp icon for non-animated hash", () => { + const url = getGuildIconUrl("123", "abc123", 128); + expect(url).toBe( + "https://cdn.discordapp.com/icons/123/abc123.webp?size=128", + ); + }); + + it("returns gif icon for animated hash", () => { + const url = getGuildIconUrl("123", "a_abc123", 64); + expect(url).toBe( + "https://cdn.discordapp.com/icons/123/a_abc123.gif?size=64", + ); + }); + + it("defaults to size 128", () => { + const url = getGuildIconUrl("123", "abc123"); + expect(url).toContain("size=128"); + }); +}); + +describe("fetchWithRateLimit", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + fetchSpy = vi.spyOn(global, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + vi.useRealTimers(); + }); + + it("returns response directly when not rate limited", async () => { + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ data: "ok" }), + } as Response); + + const response = await fetchWithRateLimit("https://example.com/api"); + expect(response.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("retries on 429 with retry-after header", async () => { + const headers = new Map([["retry-after", "0.01"]]); + let callCount = 0; + fetchSpy.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ + status: 429, + headers: { get: (key: string) => headers.get(key) ?? null }, + } as unknown as Response); + } + return Promise.resolve({ ok: true, status: 200 } as Response); + }); + + const promise = fetchWithRateLimit("https://example.com/api"); + // Advance timers to allow retries + await vi.advanceTimersByTimeAsync(100); + const response = await promise; + expect(response.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it("parses retry-after header as seconds and waits", async () => { + const headers = new Map([["retry-after", "0.001"]]); // 1ms + let callCount = 0; + fetchSpy.mockImplementation(() => { + callCount++; + if (callCount <= 2) { + return Promise.resolve({ + status: 429, + headers: { get: (key: string) => headers.get(key) ?? null }, + } as unknown as Response); + } + return Promise.resolve({ ok: true, status: 200 } as Response); + }); + + const promise = fetchWithRateLimit("https://example.com/api"); + await vi.advanceTimersByTimeAsync(100); + const response = await promise; + expect(response.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it("returns 429 after exhausting max retries", async () => { + const headers = new Map([["retry-after", "0.001"]]); + fetchSpy.mockResolvedValue({ + status: 429, + headers: { get: (key: string) => headers.get(key) ?? null }, + } as unknown as Response); + + const promise = fetchWithRateLimit("https://example.com/api"); + await vi.advanceTimersByTimeAsync(100); + const response = await promise; + expect(response.status).toBe(429); + // 1 initial + 3 retries = 4 total calls + expect(fetchSpy).toHaveBeenCalledTimes(4); + }); + + it("aborts sleep when signal fires during rate-limit wait", async () => { + const controller = new AbortController(); + const headers = new Map([["retry-after", "30"]]); // 30 seconds + let callCount = 0; + fetchSpy.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ + status: 429, + headers: { get: (key: string) => headers.get(key) ?? null }, + } as unknown as Response); + } + return Promise.resolve({ ok: true, status: 200 } as Response); + }); + + const promise = fetchWithRateLimit("https://example.com/api", { + signal: controller.signal, + }); + + // Advance a little, then abort (well before the 30s retry-after) + await vi.advanceTimersByTimeAsync(100); + controller.abort(new DOMException("Timed out", "TimeoutError")); + + await expect(promise).rejects.toThrow(); + // Should only have made 1 fetch call (the initial 429), not retried + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("throws immediately if signal already aborted before sleep", async () => { + const controller = new AbortController(); + controller.abort(new DOMException("Already aborted", "AbortError")); + + const headers = new Map([["retry-after", "1"]]); + fetchSpy.mockResolvedValue({ + status: 429, + headers: { get: (key: string) => headers.get(key) ?? null }, + } as unknown as Response); + + // Attach rejection handler immediately — no timer advance needed since + // the signal is already aborted and the throw is synchronous. + await expect( + fetchWithRateLimit("https://example.com/api", { + signal: controller.signal, + }), + ).rejects.toThrow(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("cleans up abort listener after rate-limit sleep resolves normally", async () => { + const controller = new AbortController(); + const removeListenerSpy = vi.spyOn(controller.signal, "removeEventListener"); + + const headers = new Map([["retry-after", "0.001"]]); + let callCount = 0; + fetchSpy.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ + status: 429, + headers: { get: (key: string) => headers.get(key) ?? null }, + } as unknown as Response); + } + return Promise.resolve({ ok: true, status: 200 } as Response); + }); + + const promise = fetchWithRateLimit("https://example.com/api", { + signal: controller.signal, + }); + await vi.advanceTimersByTimeAsync(100); + const response = await promise; + expect(response.status).toBe(200); + // The abort listener should have been removed after the sleep resolved + expect(removeListenerSpy).toHaveBeenCalledWith("abort", expect.any(Function)); + removeListenerSpy.mockRestore(); + }); + + it("uses 1000ms default when no retry-after header", async () => { + let callCount = 0; + fetchSpy.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ + status: 429, + headers: { get: () => null }, + } as unknown as Response); + } + return Promise.resolve({ ok: true, status: 200 } as Response); + }); + + const promise = fetchWithRateLimit("https://example.com/api"); + // Advance past the 1s default wait + await vi.advanceTimersByTimeAsync(1100); + const response = await promise; + expect(response.status).toBe(200); + }); +}); + +describe("fetchUserGuilds", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(global, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it("fetches guilds with correct authorization header", async () => { + const mockGuilds = [ + { id: "1", name: "Test Server", icon: null, owner: true, permissions: "8", features: [] }, + ]; + + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockGuilds), + } as Response); + + const guilds = await fetchUserGuilds("test-token"); + expect(guilds).toEqual(mockGuilds); + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining("/users/@me/guilds"), + expect.objectContaining({ + headers: { + Authorization: "Bearer test-token", + }, + }), + ); + }); + + it("throws on non-OK response", async () => { + fetchSpy.mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + } as Response); + + await expect(fetchUserGuilds("bad-token")).rejects.toThrow( + "Failed to fetch user guilds", + ); + }); + + it("paginates through multiple pages using after param", async () => { + // Create 200 guilds for page 1 (triggers pagination) + const page1 = Array.from({ length: 200 }, (_, i) => ({ + id: String(i + 1), + name: `Server ${i + 1}`, + icon: null, + owner: false, + permissions: "0", + features: [], + })); + const page2 = [ + { id: "201", name: "Server 201", icon: null, owner: false, permissions: "0", features: [] }, + ]; + + let callCount = 0; + fetchSpy.mockImplementation((url: string | URL | Request) => { + callCount++; + const urlStr = url.toString(); + if (callCount === 1) { + // First call — no "after" param + expect(urlStr).not.toContain("after="); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(page1), + } as Response); + } + // Second call — should have "after=200" + expect(urlStr).toContain("after=200"); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(page2), + } as Response); + }); + + const guilds = await fetchUserGuilds("test-token"); + expect(guilds).toHaveLength(201); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it("supports AbortSignal", async () => { + const controller = new AbortController(); + controller.abort(); + + fetchSpy.mockRejectedValue(new DOMException("Aborted", "AbortError")); + + await expect(fetchUserGuilds("test-token", controller.signal)).rejects.toThrow(); + }); +}); + +describe("fetchBotGuilds", () => { + let fetchSpy: ReturnType; + let savedBotApiUrl: string | undefined; + let savedBotApiSecret: string | undefined; + + beforeEach(() => { + fetchSpy = vi.spyOn(global, "fetch"); + savedBotApiUrl = process.env.BOT_API_URL; + savedBotApiSecret = process.env.BOT_API_SECRET; + }); + + afterEach(() => { + fetchSpy.mockRestore(); + // Restore env vars to prevent pollution + if (savedBotApiUrl !== undefined) { + process.env.BOT_API_URL = savedBotApiUrl; + } else { + delete process.env.BOT_API_URL; + } + if (savedBotApiSecret !== undefined) { + process.env.BOT_API_SECRET = savedBotApiSecret; + } else { + delete process.env.BOT_API_SECRET; + } + }); + + it("returns unavailable result when BOT_API_URL is not set", async () => { + delete process.env.BOT_API_URL; + + const result = await fetchBotGuilds(); + expect(result).toEqual({ available: false, guilds: [] }); + }); + + it("returns unavailable result when BOT_API_SECRET is missing", async () => { + process.env.BOT_API_URL = "http://localhost:3001"; + delete process.env.BOT_API_SECRET; + + const result = await fetchBotGuilds(); + expect(result).toEqual({ available: false, guilds: [] }); + }); + + it("returns unavailable result when bot API returns non-OK response", async () => { + process.env.BOT_API_URL = "http://localhost:3001"; + process.env.BOT_API_SECRET = "test-secret"; + + fetchSpy.mockResolvedValue({ + ok: false, + status: 503, + statusText: "Service Unavailable", + } as Response); + + const result = await fetchBotGuilds(); + expect(result).toEqual({ available: false, guilds: [] }); + }); + + it("returns unavailable result when bot API is unreachable", async () => { + process.env.BOT_API_URL = "http://localhost:3001"; + process.env.BOT_API_SECRET = "test-secret"; + + fetchSpy.mockRejectedValue(new Error("ECONNREFUSED")); + + const result = await fetchBotGuilds(); + expect(result).toEqual({ available: false, guilds: [] }); + }); + + it("forwards AbortSignal to the underlying fetch", async () => { + process.env.BOT_API_URL = "http://localhost:3001"; + process.env.BOT_API_SECRET = "test-secret"; + + const controller = new AbortController(); + controller.abort(new DOMException("Aborted", "AbortError")); + + fetchSpy.mockRejectedValue(new DOMException("Aborted", "AbortError")); + + // fetchBotGuilds catches errors internally and returns unavailable + const result = await fetchBotGuilds(controller.signal); + expect(result).toEqual({ available: false, guilds: [] }); + + // Verify signal was forwarded to fetch + expect(fetchSpy).toHaveBeenCalledWith( + "http://localhost:3001/api/guilds", + expect.objectContaining({ + signal: controller.signal, + }), + ); + }); + + it("sends Authorization header with BOT_API_SECRET", async () => { + process.env.BOT_API_URL = "http://localhost:3001"; + process.env.BOT_API_SECRET = "my-secret"; + + fetchSpy.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + } as Response); + + const result = await fetchBotGuilds(); + expect(result).toEqual({ available: true, guilds: [] }); + + expect(fetchSpy).toHaveBeenCalledWith( + "http://localhost:3001/api/guilds", + expect.objectContaining({ + headers: { Authorization: "Bearer my-secret" }, + }), + ); + }); +}); + +describe("getMutualGuilds", () => { + let fetchSpy: ReturnType; + let savedBotApiUrl: string | undefined; + let savedBotApiSecret: string | undefined; + + beforeEach(() => { + fetchSpy = vi.spyOn(global, "fetch"); + savedBotApiUrl = process.env.BOT_API_URL; + savedBotApiSecret = process.env.BOT_API_SECRET; + }); + + afterEach(() => { + fetchSpy.mockRestore(); + if (savedBotApiUrl !== undefined) { + process.env.BOT_API_URL = savedBotApiUrl; + } else { + delete process.env.BOT_API_URL; + } + if (savedBotApiSecret !== undefined) { + process.env.BOT_API_SECRET = savedBotApiSecret; + } else { + delete process.env.BOT_API_SECRET; + } + }); + + it("returns only guilds where bot is present", async () => { + const userGuilds = [ + { id: "1", name: "Server 1", icon: null, owner: true, permissions: "8", features: [] }, + { id: "2", name: "Server 2", icon: null, owner: false, permissions: "0", features: [] }, + { id: "3", name: "Server 3", icon: null, owner: false, permissions: "0", features: [] }, + ]; + const botGuilds = [ + { id: "1", name: "Server 1", icon: null }, + { id: "3", name: "Server 3", icon: null }, + ]; + + process.env.BOT_API_URL = "http://localhost:3001"; + process.env.BOT_API_SECRET = "test-secret"; + + fetchSpy.mockImplementation((url: string | URL | Request) => { + const urlStr = url.toString(); + if (urlStr.includes("/users/@me/guilds")) { + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(userGuilds) } as Response); + } + if (urlStr.includes("/api/guilds")) { + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(botGuilds) } as Response); + } + return Promise.reject(new Error(`Unexpected fetch URL: ${urlStr}`)); + }); + + const mutualGuilds = await getMutualGuilds("test-token"); + + expect(mutualGuilds).toHaveLength(2); + expect(mutualGuilds[0].id).toBe("1"); + expect(mutualGuilds[1].id).toBe("3"); + expect(mutualGuilds[0].botPresent).toBe(true); + }); + + it("returns all user guilds unfiltered when bot API fails", async () => { + const userGuilds = [ + { id: "1", name: "Server 1", icon: null, owner: true, permissions: "8", features: [] }, + { id: "2", name: "Server 2", icon: null, owner: false, permissions: "0", features: [] }, + ]; + + process.env.BOT_API_URL = "http://localhost:3001"; + process.env.BOT_API_SECRET = "test-secret"; + + fetchSpy.mockImplementation((url: string | URL | Request) => { + const urlStr = url.toString(); + if (urlStr.includes("/users/@me/guilds")) { + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(userGuilds) } as Response); + } + if (urlStr.includes("/api/guilds")) { + return Promise.resolve({ ok: false, status: 500, statusText: "Internal Server Error" } as Response); + } + return Promise.reject(new Error(`Unexpected fetch URL: ${urlStr}`)); + }); + + const mutualGuilds = await getMutualGuilds("test-token"); + + expect(mutualGuilds).toHaveLength(2); + expect(mutualGuilds[0].botPresent).toBe(false); + expect(mutualGuilds[1].botPresent).toBe(false); + }); + + it("returns all user guilds when no BOT_API_URL is set", async () => { + const userGuilds = [ + { id: "1", name: "Server 1", icon: null, owner: true, permissions: "8", features: [] }, + ]; + + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(userGuilds), + } as Response); + + delete process.env.BOT_API_URL; + + const mutualGuilds = await getMutualGuilds("test-token"); + + expect(mutualGuilds).toHaveLength(1); + expect(mutualGuilds[0].botPresent).toBe(false); + }); +}); diff --git a/web/tests/lib/utils.test.ts b/web/tests/lib/utils.test.ts new file mode 100644 index 00000000..0bb5b269 --- /dev/null +++ b/web/tests/lib/utils.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { cn } from "@/lib/utils"; + +describe("cn utility", () => { + it("merges class names", () => { + expect(cn("foo", "bar")).toBe("foo bar"); + }); + + it("handles conditional classes", () => { + expect(cn("base", false && "hidden", "visible")).toBe("base visible"); + }); + + it("resolves tailwind conflicts", () => { + expect(cn("p-4", "p-2")).toBe("p-2"); + }); + + it("handles empty inputs", () => { + expect(cn()).toBe(""); + }); + + it("handles undefined and null", () => { + expect(cn("base", undefined, null, "end")).toBe("base end"); + }); +}); diff --git a/web/tests/middleware.test.ts b/web/tests/middleware.test.ts new file mode 100644 index 00000000..e2db5382 --- /dev/null +++ b/web/tests/middleware.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { config, proxy } from "@/proxy"; + +// Mock next-auth/jwt +vi.mock("next-auth/jwt", () => ({ + getToken: vi.fn(), +})); + +describe("proxy config", () => { + it("protects dashboard routes", () => { + expect(config.matcher).toContain("/dashboard/:path*"); + }); + + it("does not protect root or login", () => { + expect(config.matcher).not.toContain("/"); + expect(config.matcher).not.toContain("/login"); + }); +}); + +describe("proxy function", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("redirects to /login when no token exists", async () => { + const { getToken } = await import("next-auth/jwt"); + vi.mocked(getToken).mockResolvedValue(null); + + const mockRequest = { + url: "http://localhost:3000/dashboard", + nextUrl: new URL("http://localhost:3000/dashboard"), + } as Parameters[0]; + + const response = await proxy(mockRequest); + + // Should be a redirect response + expect(response.status).toBe(307); + const location = response.headers.get("location"); + expect(location).toContain("/login"); + expect(location).toContain("callbackUrl="); + }); + + it("includes the original pathname as callbackUrl in redirect", async () => { + const { getToken } = await import("next-auth/jwt"); + vi.mocked(getToken).mockResolvedValue(null); + + const mockRequest = { + url: "http://localhost:3000/dashboard/settings", + nextUrl: new URL("http://localhost:3000/dashboard/settings"), + } as Parameters[0]; + + const response = await proxy(mockRequest); + + const location = response.headers.get("location"); + expect(location).toContain( + encodeURIComponent("/dashboard/settings"), + ); + }); + + it("redirects to /login when token has RefreshTokenError", async () => { + const { getToken } = await import("next-auth/jwt"); + vi.mocked(getToken).mockResolvedValue({ + sub: "123", + accessToken: "expired-token", + id: "user-123", + name: "Test", + email: "test@test.com", + picture: null, + error: "RefreshTokenError", + iat: 0, + exp: 0, + jti: "", + }); + + const mockRequest = { + url: "http://localhost:3000/dashboard", + nextUrl: new URL("http://localhost:3000/dashboard"), + } as Parameters[0]; + + const response = await proxy(mockRequest); + + expect(response.status).toBe(307); + const location = response.headers.get("location"); + expect(location).toContain("/login"); + expect(location).toContain("callbackUrl="); + }); + + it("allows access when valid token exists", async () => { + const { getToken } = await import("next-auth/jwt"); + vi.mocked(getToken).mockResolvedValue({ + sub: "123", + accessToken: "valid-token", + id: "user-123", + name: "Test", + email: "test@test.com", + picture: null, + iat: 0, + exp: 0, + jti: "", + }); + + const mockRequest = { + url: "http://localhost:3000/dashboard", + nextUrl: new URL("http://localhost:3000/dashboard"), + } as Parameters[0]; + + const response = await proxy(mockRequest); + + // NextResponse.next() returns a response that passes through (not a redirect) + expect(response.status).not.toBe(307); + expect(response.headers.get("location")).toBeNull(); + }); +}); diff --git a/web/tests/setup.ts b/web/tests/setup.ts new file mode 100644 index 00000000..e2621932 --- /dev/null +++ b/web/tests/setup.ts @@ -0,0 +1,7 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(() => { + cleanup(); +}); diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 00000000..93bf954b --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,41 @@ +{ + "compilerOptions": { + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./src/*" + ] + }, + "target": "ES2020" + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 00000000..102a98ac --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + setupFiles: ["./tests/setup.ts"], + include: ["tests/**/*.test.{ts,tsx}"], + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + include: ["src/**/*.{ts,tsx}"], + exclude: [ + "src/types/**", + "src/app/layout.tsx", + "src/app/globals.css", + "src/components/ui/**", + ], + thresholds: { + statements: 80, + branches: 80, + functions: 80, + lines: 80, + }, + }, + }, + resolve: { + alias: { + "@": resolve(__dirname, "./src"), + "server-only": resolve(__dirname, "./tests/__mocks__/server-only.ts"), + }, + }, +});