From 0f3c951fc155171c19f48faf15ea7f9e17918224 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 30 Mar 2026 21:08:20 -0500 Subject: [PATCH] wip --- .dockerignore | 17 + .env.example | 23 + .github/workflows/build.yml | 51 + .github/workflows/ci.yml | 23 + .gitignore | 13 + .husky/pre-commit | 1 + .prettierignore | 8 + .prettierrc.json | 5 + LICENSE | 6 + README.md | 128 + SECURITY.md | 27 + api/Dockerfile | 16 + api/eslint.config.js | 37 + api/package.json | 51 + api/src/db/audit.ts | 13 + api/src/db/mongo.ts | 80 + api/src/db/sqlite.ts | 103 + api/src/db/types.ts | 70 + api/src/index.ts | 65 + api/src/jobs/inviteSync.ts | 23 + api/src/lib/async-handler.ts | 13 + api/src/lib/env.ts | 29 + api/src/lib/flags.ts | 10 + api/src/middleware/auth.ts | 38 + api/src/middleware/errors.ts | 10 + api/src/routes/auth.ts | 70 + api/src/routes/dashboard.ts | 34 + api/src/routes/invites.ts | 116 + api/src/routes/users.ts | 241 + api/src/seed.ts | 96 + .../types/better-sqlite3-session-store.d.ts | 19 + api/src/types/express-session.d.ts | 10 + api/tsconfig.json | 17 + compose.override.example.yml | 20 + compose.yml | 31 + deploy/s6/s6-services.service | 15 + deploy/s6/stoat-admin/finish | 4 + deploy/s6/stoat-admin/log/run | 3 + deploy/s6/stoat-admin/run | 11 + deploy/s6/stoat/finish | 4 + deploy/s6/stoat/log/run | 3 + deploy/s6/stoat/run | 5 + docs/stoat-admin-design.md | 70 +- docs/stoat-admin-tasks.md | 56 +- package.json | 34 + pnpm-lock.yaml | 5300 +++++++++++++++++ pnpm-workspace.yaml | 3 + turbo.json | 20 + web/.dockerignore | 4 + web/Caddyfile | 14 + web/Dockerfile | 19 + web/coraza.conf | 5 + web/eslint.config.js | 42 + web/index.html | 12 + web/package.json | 44 + web/src/components/Layout.tsx | 72 + web/src/index.css | 44 + web/src/lib/api.ts | 33 + web/src/lib/auth.tsx | 93 + web/src/lib/format.ts | 15 + web/src/lib/status.ts | 48 + web/src/lib/types.ts | 75 + web/src/main.tsx | 20 + web/src/router.tsx | 58 + web/src/views/Dashboard.tsx | 85 + web/src/views/Invites.tsx | 221 + web/src/views/Login.tsx | 90 + web/src/views/UserDetail.tsx | 280 + web/src/views/Users.tsx | 157 + web/tsconfig.json | 21 + web/vite.config.ts | 7 + 71 files changed, 8453 insertions(+), 48 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100755 .husky/pre-commit create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 api/Dockerfile create mode 100644 api/eslint.config.js create mode 100644 api/package.json create mode 100644 api/src/db/audit.ts create mode 100644 api/src/db/mongo.ts create mode 100644 api/src/db/sqlite.ts create mode 100644 api/src/db/types.ts create mode 100644 api/src/index.ts create mode 100644 api/src/jobs/inviteSync.ts create mode 100644 api/src/lib/async-handler.ts create mode 100644 api/src/lib/env.ts create mode 100644 api/src/lib/flags.ts create mode 100644 api/src/middleware/auth.ts create mode 100644 api/src/middleware/errors.ts create mode 100644 api/src/routes/auth.ts create mode 100644 api/src/routes/dashboard.ts create mode 100644 api/src/routes/invites.ts create mode 100644 api/src/routes/users.ts create mode 100644 api/src/seed.ts create mode 100644 api/src/types/better-sqlite3-session-store.d.ts create mode 100644 api/src/types/express-session.d.ts create mode 100644 api/tsconfig.json create mode 100644 compose.override.example.yml create mode 100644 compose.yml create mode 100644 deploy/s6/s6-services.service create mode 100755 deploy/s6/stoat-admin/finish create mode 100755 deploy/s6/stoat-admin/log/run create mode 100755 deploy/s6/stoat-admin/run create mode 100755 deploy/s6/stoat/finish create mode 100755 deploy/s6/stoat/log/run create mode 100755 deploy/s6/stoat/run create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 turbo.json create mode 100644 web/.dockerignore create mode 100644 web/Caddyfile create mode 100644 web/Dockerfile create mode 100644 web/coraza.conf create mode 100644 web/eslint.config.js create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/src/components/Layout.tsx create mode 100644 web/src/index.css create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/auth.tsx create mode 100644 web/src/lib/format.ts create mode 100644 web/src/lib/status.ts create mode 100644 web/src/lib/types.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/router.tsx create mode 100644 web/src/views/Dashboard.tsx create mode 100644 web/src/views/Invites.tsx create mode 100644 web/src/views/Login.tsx create mode 100644 web/src/views/UserDetail.tsx create mode 100644 web/src/views/Users.tsx create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ec31649 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +node_modules/ +dist/ +.turbo/ +.husky/_/ +*.db +*.sqlite +.env +.env.local +.env.production +secrets.env +data/ +.git/ +README.md +compose*.yml +test/ +tests/ +coverage/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b88f587 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# MongoDB connection string for the Stoat database. +MONGODB=mongodb://database:27017 + +# Resend credentials for invite email delivery. +RESEND_API_KEY=re_xxxxxxxxxxxx +RESEND_FROM_EMAIL=noreply@yourdomain.com + +# Express session signing secret. Generate with: openssl rand -base64 32 +SESSION_SECRET= + +# Stoat instance metadata used in invite emails. +INSTANCE_URL=https://chat.yourdomain.com +INSTANCE_NAME=My Stoat Instance + +# Admin API listen port and admin web origin for CORS. +ADMIN_API_PORT=5181 +ADMIN_WEB_ORIGIN=http://localhost:5180 + +# Compose-level convenience variables. +ADMIN_BIND_IP=127.0.0.1 +ADMIN_WEB_PORT=5180 +ADMIN_WEB_API_URL=http://127.0.0.1:5181 + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..c5dd62d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,51 @@ +name: Build And Push + +on: + push: + branches: + - main + tags: + - "v*" + +jobs: + build-api: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@v6 + with: + context: ./api + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/stoat-admin-api:latest + ghcr.io/${{ github.repository_owner }}/stoat-admin-api:${{ github.sha }} + + build-web: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@v6 + with: + context: ./web + push: true + build-args: | + VITE_API_URL=http://127.0.0.1:5181 + tags: | + ghcr.io/${{ github.repository_owner }}/stoat-admin-web:latest + ghcr.io/${{ github.repository_owner }}/stoat-admin-web:${{ github.sha }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e3c2b8a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --no-frozen-lockfile + - run: pnpm lint + - run: pnpm build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa76919 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +.turbo/ +.husky/_/ +*.db +*.sqlite +.env +.env.local +.env.production +secrets.env +data/ +coverage/ +.DS_Store diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..5ee7abd --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm exec lint-staged diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..f78049e --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +.git +.turbo +node_modules +dist +data +*.db +*.sqlite + diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..2f34662 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "none" +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..011cdbf --- /dev/null +++ b/LICENSE @@ -0,0 +1,6 @@ +SPDX-License-Identifier: AGPL-3.0-only + +This repository is intended to be distributed under the GNU Affero General Public +License v3.0 only. Replace this placeholder with the full AGPL-3.0 license text +before release. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..927aa13 --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# Stoat Admin + +Stoat Admin is a lightweight self-hosted moderation and invite dashboard for a Stoat chat instance. It runs as a separate Podman Compose stack, joins the Stoat network to talk to MongoDB directly, and is intended to be reachable only over WireGuard. + +## Features + +- Single-admin login with Argon2id password hashing and SQLite-backed sessions +- Invite creation, revocation, and acceptance tracking +- User listing, lookup, ban, unban, and scheduled deletion actions +- Dashboard stats for users, pending invites, and recent bans +- `pnpm` workspace with Turborepo coordinating cross-package tasks +- Example Podman Compose, s6 service directories, and GitHub Actions workflows + +## Project Layout + +```text +. +├── api/ # Express + TypeScript backend +├── web/ # Vite + React + TypeScript frontend +├── deploy/s6/ # Example s6 service directories and systemd unit +├── compose.yml # Production-oriented compose file +├── compose.override.example.yml +├── docs/ # Design and task references +└── .env.example +``` + +## Prerequisites + +- Node 22+ +- `pnpm` via Corepack +- Turborepo is installed through the workspace dependencies +- A Stoat deployment with MongoDB reachable on the shared container network +- `invite_only = true` in Stoat's `Revolt.toml` +- Podman or Docker-compatible compose support +- WireGuard or another private network boundary for admin access + +## Quick Start + +1. Enable `pnpm`: + +```sh +corepack enable +``` + +2. Install dependencies: + +```sh +COREPACK_HOME=/tmp/corepack pnpm install +``` + +3. Copy the environment template and fill in the real values: + +```sh +cp .env.example .env +``` + +4. Seed the admin account from the root workspace: + +```sh +COREPACK_HOME=/tmp/corepack pnpm seed -- --username admin --password '' +``` + +5. Run both packages together through Turborepo: + +```sh +COREPACK_HOME=/tmp/corepack pnpm dev +``` + +Useful targeted variants: + +```sh +COREPACK_HOME=/tmp/corepack pnpm dev:api +COREPACK_HOME=/tmp/corepack pnpm dev:web +``` + +## Configuration + +| Variable | Description | +| ------------------- | ------------------------------------------------ | +| `MONGODB` | MongoDB connection string for the Stoat database | +| `RESEND_API_KEY` | Resend API key for invite delivery | +| `RESEND_FROM_EMAIL` | Sender address for invite messages | +| `SESSION_SECRET` | Express session signing secret | +| `INSTANCE_URL` | Public Stoat URL used in invite links | +| `INSTANCE_NAME` | Human-readable instance name used in copy | +| `ADMIN_API_PORT` | Listen port for `admin-api` | +| `ADMIN_WEB_ORIGIN` | Exact browser origin allowed by CORS | +| `ADMIN_BIND_IP` | Compose bind IP for admin services | +| `ADMIN_WEB_PORT` | Host port for the frontend | +| `ADMIN_WEB_API_URL` | API base URL baked into the frontend build | + +## Deployment + +The repo ships with a standalone `compose.yml` that expects an external `stoat_default` network. Update the network name if your Stoat stack uses a different one. + +For supervised deployments: + +1. Install `s6` +2. Copy `deploy/s6/stoat` and `deploy/s6/stoat-admin` into `/etc/s6-services` +3. Adjust service paths and network names +4. Copy `deploy/s6/s6-services.service` into `/etc/systemd/system/` +5. Enable the unit: + +```sh +sudo systemctl daemon-reload +sudo systemctl enable --now s6-services +``` + +Common operations: + +```sh +s6-svc -r /etc/s6-services/stoat-admin +s6-svc -d /etc/s6-services/stoat-admin +s6-svc -u /etc/s6-services/stoat-admin +s6-svstat /etc/s6-services/stoat-admin +tail -f /var/log/s6/stoat-admin/current | s6-tai64nlocal +``` + +## Development Notes + +- The backend uses SQLite for admin credentials, audit logs, and invite metadata, and MongoDB for Stoat state. +- The frontend talks directly to the API with cookie-based auth and TanStack Query. +- Root task orchestration is handled by Turborepo through [turbo.json](/home/jasonross/workspace/stoat-admin/turbo.json). +- The current repo state is a first implementation slice based on the design docs in [docs/stoat-admin-design.md](/home/jasonross/workspace/stoat-admin/docs/stoat-admin-design.md) and [docs/stoat-admin-tasks.md](/home/jasonross/workspace/stoat-admin/docs/stoat-admin-tasks.md). + +## Contributing + +Keep infrastructure-specific values out of committed files. Prefer changes that preserve the split between the standalone admin stack and the main Stoat stack. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e889755 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security Policy + +## Security Model + +Stoat Admin is designed for private administration over a WireGuard-restricted network. The intended deployment model is: + +- `admin-web` and `admin-api` bind only to a private interface such as `wg0` +- the dashboard is not exposed through the public Stoat reverse proxy +- the API still requires session-based authentication with an Argon2id-hashed admin credential + +This means WireGuard limits network access and the application session limits user access. Both layers are expected in production. + +## Reporting + +If you discover a security issue, avoid opening a public issue with exploit details. Share the report privately with the maintainer and include: + +- affected version or commit +- reproduction steps +- impact +- any suggested mitigation + +## Deployment Notes + +- Keep `SESSION_SECRET` and `RESEND_API_KEY` out of the repository. +- Restrict permissions on the SQLite database file mounted at `/data/admin.db`. +- Set `ADMIN_WEB_ORIGIN` precisely. Do not use `*`. +- Verify the compose port bindings are limited to the intended private interface. diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..901d4e5 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,16 @@ +FROM node:22-slim AS build +WORKDIR /app +COPY package.json ./ +RUN corepack enable && pnpm install --frozen-lockfile=false +COPY tsconfig.json ./ +COPY src/ ./src/ +RUN pnpm build + +FROM node:22-slim +WORKDIR /app +COPY package.json ./ +RUN corepack enable && pnpm install --prod --frozen-lockfile=false +COPY --from=build /app/dist ./dist +RUN mkdir -p /data +EXPOSE 5181 +CMD ["node", "dist/index.js"] diff --git a/api/eslint.config.js b/api/eslint.config.js new file mode 100644 index 0000000..6fb5db0 --- /dev/null +++ b/api/eslint.config.js @@ -0,0 +1,37 @@ +import js from "@eslint/js"; +import globals from "globals"; +import tsParser from "@typescript-eslint/parser"; +import tsPlugin from "@typescript-eslint/eslint-plugin"; + +export default [ + { + ignores: ["dist/**", "node_modules/**"] + }, + js.configs.recommended, + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module" + }, + globals: { + ...globals.node + } + }, + plugins: { + "@typescript-eslint": tsPlugin + }, + rules: { + ...tsPlugin.configs.recommended.rules, + "no-undef": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_" + } + ] + } + } +]; diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..3542fac --- /dev/null +++ b/api/package.json @@ -0,0 +1,51 @@ +{ + "name": "stoat-admin-api", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "AGPL-3.0-only", + "packageManager": "pnpm@10.6.3", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "tsx watch src/index.ts", + "typecheck": "tsc -p tsconfig.json --noEmit", + "build": "tsc -p tsconfig.json", + "check": "pnpm lint && pnpm typecheck", + "start": "node dist/index.js", + "seed": "tsx src/seed.ts", + "lint": "eslint ." + }, + "dependencies": { + "argon2": "^0.44.0", + "better-sqlite3": "^12.8.0", + "better-sqlite3-session-store": "^0.1.0", + "cors": "^2.8.5", + "dotenv": "^17.3.1", + "express": "^5.2.1", + "express-session": "^1.18.1", + "helmet": "^8.0.0", + "mongodb": "^7.1.1", + "nanoid": "^5.1.5", + "node-cron": "^4.0.7", + "resend": "^6.10.0", + "ulid": "^3.0.2", + "zod": "^4.3.6" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/better-sqlite3": "^7.6.12", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/express-session": "^1.18.1", + "@types/node": "24.12.0", + "@types/node-cron": "^3.0.11", + "eslint": "^10.1.0", + "globals": "^17.4.0", + "tsx": "^4.19.2", + "typescript": "^6.0.2", + "@typescript-eslint/eslint-plugin": "^8.18.2", + "@typescript-eslint/parser": "^8.18.2" + } +} diff --git a/api/src/db/audit.ts b/api/src/db/audit.ts new file mode 100644 index 0000000..4db43d1 --- /dev/null +++ b/api/src/db/audit.ts @@ -0,0 +1,13 @@ +import { sqlite } from "./sqlite.js"; + +const insertAuditLog = sqlite.prepare<[string, string, string | null]>( + "INSERT INTO audit_log (action, target, details) VALUES (?, ?, ?)" +); + +export function logAction( + action: string, + target: string, + details?: Record +): void { + insertAuditLog.run(action, target, details ? JSON.stringify(details) : null); +} diff --git a/api/src/db/mongo.ts b/api/src/db/mongo.ts new file mode 100644 index 0000000..80c1614 --- /dev/null +++ b/api/src/db/mongo.ts @@ -0,0 +1,80 @@ +import { MongoClient, type Collection, type Db } from "mongodb"; + +import { env } from "../lib/env.js"; +import type { + AccountDocument, + InviteDocument, + SessionDocument, + StrikeDocument, + UserDocument +} from "./types.js"; + +let client: MongoClient | null = null; +let db: Db | null = null; + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export async function connectMongo(): Promise { + if (db) { + return db; + } + + let attempt = 0; + + for (;;) { + try { + client = new MongoClient(env.MONGODB); + await client.connect(); + db = client.db("revolt"); + return db; + } catch (error) { + const waitMs = Math.min(1000 * 2 ** attempt, 30_000); + attempt += 1; + console.error( + `MongoDB connection failed. Retrying in ${waitMs}ms.`, + error + ); + await delay(waitMs); + } + } +} + +export function getDb(): Db { + if (!db) { + throw new Error("MongoDB has not been connected yet"); + } + + return db; +} + +export function accounts(): Collection { + return getDb().collection("accounts"); +} + +export function users(): Collection { + return getDb().collection("users"); +} + +export function sessions(): Collection { + return getDb().collection("sessions"); +} + +export function invites(): Collection { + return getDb().collection("invites"); +} + +export function safetyStrikes(): Collection { + return getDb().collection("safety_strikes"); +} + +export async function closeMongo(): Promise { + if (client) { + await client.close(); + client = null; + db = null; + } +} diff --git a/api/src/db/sqlite.ts b/api/src/db/sqlite.ts new file mode 100644 index 0000000..42d34c9 --- /dev/null +++ b/api/src/db/sqlite.ts @@ -0,0 +1,103 @@ +import { existsSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +import Database from "better-sqlite3"; + +import type { AdminUserRecord, InviteRecord } from "./types.js"; + +function resolveSqlitePath(): string { + if (existsSync("/data")) { + return "/data/admin.db"; + } + + return resolve(process.cwd(), "data", "admin.db"); +} + +export const sqlitePath = resolveSqlitePath(); + +mkdirSync(dirname(sqlitePath), { recursive: true }); + +export const sqlite = new Database(sqlitePath); +sqlite.pragma("journal_mode = WAL"); + +sqlite.exec(` + CREATE TABLE IF NOT EXISTS admin_user ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS invite_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + email TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT, + accepted_at TEXT, + resend_message_id TEXT + ); + + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + target TEXT NOT NULL, + details TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); +`); + +export const statements = { + getAdminUserByUsername: sqlite.prepare<[string], AdminUserRecord>( + "SELECT id, username, password_hash FROM admin_user WHERE username = ?" + ), + getFirstAdminUser: sqlite.prepare<[], AdminUserRecord>( + "SELECT id, username, password_hash FROM admin_user ORDER BY id ASC LIMIT 1" + ), + insertAdminUser: sqlite.prepare<[string, string]>( + "INSERT INTO admin_user (id, username, password_hash) VALUES (1, ?, ?)" + ), + updateAdminPasswordByUsername: sqlite.prepare<[string, string]>( + "UPDATE admin_user SET password_hash = ? WHERE username = ?" + ), + listInviteRecords: sqlite.prepare<[], InviteRecord>( + `SELECT id, code, email, status, created_at, expires_at, accepted_at, resend_message_id + FROM invite_records + ORDER BY created_at DESC` + ), + countInviteRecords: sqlite.prepare<[], { count: number }>( + "SELECT COUNT(*) AS count FROM invite_records" + ), + countPendingInvites: sqlite.prepare<[], { count: number }>( + "SELECT COUNT(*) AS count FROM invite_records WHERE status = 'pending'" + ), + insertInviteRecord: sqlite.prepare<[string, string, string | null]>( + "INSERT INTO invite_records (code, email, status, expires_at) VALUES (?, ?, 'pending', ?)" + ), + getInviteRecordByCode: sqlite.prepare<[string], InviteRecord>( + `SELECT id, code, email, status, created_at, expires_at, accepted_at, resend_message_id + FROM invite_records + WHERE code = ?` + ), + updateInviteResendMessage: sqlite.prepare<[string | null, string]>( + "UPDATE invite_records SET resend_message_id = ? WHERE code = ?" + ), + markInviteRevoked: sqlite.prepare<[string]>( + "UPDATE invite_records SET status = 'revoked' WHERE code = ?" + ), + selectPendingInvites: sqlite.prepare<[], InviteRecord>( + `SELECT id, code, email, status, created_at, expires_at, accepted_at, resend_message_id + FROM invite_records + WHERE status = 'pending' + ORDER BY created_at DESC` + ), + markInviteAccepted: sqlite.prepare<[string]>( + "UPDATE invite_records SET status = 'accepted', accepted_at = datetime('now') WHERE code = ?" + ), + markInviteExpired: sqlite.prepare<[string]>( + "UPDATE invite_records SET status = 'expired' WHERE code = ?" + ), + countRecentBans: sqlite.prepare<[], { count: number }>( + "SELECT COUNT(*) AS count FROM audit_log WHERE action = 'user_banned' AND created_at > datetime('now', '-30 days')" + ) +}; diff --git a/api/src/db/types.ts b/api/src/db/types.ts new file mode 100644 index 0000000..6d10eba --- /dev/null +++ b/api/src/db/types.ts @@ -0,0 +1,70 @@ +export interface AccountDocument { + _id: string; + email: string; + email_normalised?: string; + disabled: boolean; + spam?: boolean; + verification?: { + status: "Verified" | "Pending" | "Moving"; + }; + deletion?: { + status: "Scheduled" | "WaitingForVerification" | "Deleted"; + after?: string; + }; + lockout?: { + attempts: number; + expiry: string; + }; +} + +export interface UserDocument { + _id: string; + username: string; + discriminator: string; + flags?: number; + avatar?: unknown; +} + +export interface SessionDocument { + _id: string; + user_id: string; +} + +export interface InviteDocument { + _id: string; +} + +export interface StrikeDocument { + _id: string; + user_id: string; + reason: string; + type?: "strike" | "suspension" | "ban"; + case_id?: string; +} + +export type InviteRecordStatus = "pending" | "accepted" | "revoked" | "expired"; + +export interface InviteRecord { + id: number; + code: string; + email: string; + status: InviteRecordStatus; + created_at: string; + expires_at: string | null; + accepted_at: string | null; + resend_message_id: string | null; +} + +export interface AuditLogRecord { + id: number; + action: string; + target: string; + details: string | null; + created_at: string; +} + +export interface AdminUserRecord { + id: number; + username: string; + password_hash: string; +} diff --git a/api/src/index.ts b/api/src/index.ts new file mode 100644 index 0000000..332d46e --- /dev/null +++ b/api/src/index.ts @@ -0,0 +1,65 @@ +import "dotenv/config"; + +import cors from "cors"; +import express from "express"; +import helmet from "helmet"; +import cron from "node-cron"; + +import "./db/sqlite.js"; +import { connectMongo } from "./db/mongo.js"; +import { syncInviteStatuses } from "./jobs/inviteSync.js"; +import { env } from "./lib/env.js"; +import { errorHandler, notFound } from "./middleware/errors.js"; +import { requireAuth, sessionMiddleware } from "./middleware/auth.js"; +import { authRouter } from "./routes/auth.js"; +import { dashboardRouter } from "./routes/dashboard.js"; +import { invitesRouter } from "./routes/invites.js"; +import { usersRouter } from "./routes/users.js"; + +async function main(): Promise { + await connectMongo(); + + const app = express(); + + app.use( + helmet({ + crossOriginResourcePolicy: false + }) + ); + app.use( + cors({ + origin: env.ADMIN_WEB_ORIGIN, + credentials: true + }) + ); + app.use(express.json()); + app.use(sessionMiddleware); + + app.get("/api/health", (_req, res) => { + res.status(200).json({ ok: true }); + }); + + app.use("/api/auth", authRouter); + app.use("/api/invites", requireAuth, invitesRouter); + app.use("/api/users", requireAuth, usersRouter); + app.use("/api/dashboard", requireAuth, dashboardRouter); + + app.use(notFound); + app.use(errorHandler); + + await syncInviteStatuses(); + cron.schedule("*/5 * * * *", () => { + void syncInviteStatuses().catch((error) => { + console.error("Invite sync failed", error); + }); + }); + + app.listen(env.ADMIN_API_PORT, () => { + console.log(`admin-api listening on :${env.ADMIN_API_PORT}`); + }); +} + +void main().catch((error) => { + console.error("Failed to start admin-api", error); + process.exit(1); +}); diff --git a/api/src/jobs/inviteSync.ts b/api/src/jobs/inviteSync.ts new file mode 100644 index 0000000..d9229f8 --- /dev/null +++ b/api/src/jobs/inviteSync.ts @@ -0,0 +1,23 @@ +import { invites } from "../db/mongo.js"; +import { statements } from "../db/sqlite.js"; + +export async function syncInviteStatuses(): Promise { + const pendingInvites = statements.selectPendingInvites.all(); + + for (const record of pendingInvites) { + const inviteExists = await invites().findOne({ _id: record.code }); + + if (!inviteExists) { + statements.markInviteAccepted.run(record.code); + continue; + } + + if ( + record.expires_at && + new Date(record.expires_at).getTime() < Date.now() + ) { + statements.markInviteExpired.run(record.code); + await invites().deleteOne({ _id: record.code }); + } + } +} diff --git a/api/src/lib/async-handler.ts b/api/src/lib/async-handler.ts new file mode 100644 index 0000000..5155edf --- /dev/null +++ b/api/src/lib/async-handler.ts @@ -0,0 +1,13 @@ +import type { NextFunction, Request, RequestHandler, Response } from "express"; + +type AsyncRouteHandler = ( + req: Request, + res: Response, + next: NextFunction +) => Promise; + +export function asyncHandler(handler: AsyncRouteHandler): RequestHandler { + return (req, res, next) => { + void handler(req, res, next).catch(next); + }; +} diff --git a/api/src/lib/env.ts b/api/src/lib/env.ts new file mode 100644 index 0000000..a10e942 --- /dev/null +++ b/api/src/lib/env.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +const envSchema = z.object({ + NODE_ENV: z + .enum(["development", "test", "production"]) + .default("development"), + MONGODB: z.string().min(1, "MONGODB is required"), + RESEND_API_KEY: z.string().min(1, "RESEND_API_KEY is required"), + RESEND_FROM_EMAIL: z + .string() + .email("RESEND_FROM_EMAIL must be a valid email"), + SESSION_SECRET: z + .string() + .min(32, "SESSION_SECRET must be at least 32 characters"), + INSTANCE_URL: z.string().url("INSTANCE_URL must be a valid URL"), + INSTANCE_NAME: z.string().min(1, "INSTANCE_NAME is required"), + ADMIN_API_PORT: z.coerce.number().int().positive().default(5181), + ADMIN_WEB_ORIGIN: z.string().url("ADMIN_WEB_ORIGIN must be a valid URL") +}); + +const parsedEnv = envSchema.safeParse(process.env); + +if (!parsedEnv.success) { + console.error("Invalid environment configuration."); + console.error(JSON.stringify(parsedEnv.error.flatten().fieldErrors, null, 2)); + throw new Error("Environment validation failed"); +} + +export const env = parsedEnv.data; diff --git a/api/src/lib/flags.ts b/api/src/lib/flags.ts new file mode 100644 index 0000000..7bcd8f1 --- /dev/null +++ b/api/src/lib/flags.ts @@ -0,0 +1,10 @@ +export const USER_FLAG_SUSPENDED = 1; +export const USER_FLAG_DELETED = 2; +export const USER_FLAG_BANNED = 4; + +export function hasFlag( + flags: number | null | undefined, + mask: number +): boolean { + return ((flags ?? 0) & mask) === mask; +} diff --git a/api/src/middleware/auth.ts b/api/src/middleware/auth.ts new file mode 100644 index 0000000..97a43d3 --- /dev/null +++ b/api/src/middleware/auth.ts @@ -0,0 +1,38 @@ +import session from "express-session"; +import connectSqlite3 from "better-sqlite3-session-store"; +import type { RequestHandler } from "express"; + +import { env } from "../lib/env.js"; +import { sqlite } from "../db/sqlite.js"; + +const SQLiteStore = connectSqlite3(session); +export const SESSION_COOKIE_NAME = "stoat-admin.sid"; + +export const sessionMiddleware = session({ + name: SESSION_COOKIE_NAME, + secret: env.SESSION_SECRET, + resave: false, + saveUninitialized: false, + store: new SQLiteStore({ + client: sqlite, + expired: { + clear: true, + intervalMs: 15 * 60 * 1000 + } + }), + cookie: { + maxAge: 2 * 60 * 60 * 1000, + httpOnly: true, + sameSite: "strict", + secure: false + } +}); + +export const requireAuth: RequestHandler = (req, res, next) => { + if (!req.session.userId) { + res.status(401).json({ error: "Not authenticated" }); + return; + } + + next(); +}; diff --git a/api/src/middleware/errors.ts b/api/src/middleware/errors.ts new file mode 100644 index 0000000..7d754c7 --- /dev/null +++ b/api/src/middleware/errors.ts @@ -0,0 +1,10 @@ +import type { ErrorRequestHandler, RequestHandler } from "express"; + +export const notFound: RequestHandler = (_req, res) => { + res.status(404).json({ error: "Not found" }); +}; + +export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => { + console.error(error); + res.status(500).json({ error: "Internal server error" }); +}; diff --git a/api/src/routes/auth.ts b/api/src/routes/auth.ts new file mode 100644 index 0000000..39cbce6 --- /dev/null +++ b/api/src/routes/auth.ts @@ -0,0 +1,70 @@ +import argon2 from "argon2"; +import { Router } from "express"; +import { z } from "zod"; + +import { statements } from "../db/sqlite.js"; +import { asyncHandler } from "../lib/async-handler.js"; +import { requireAuth, SESSION_COOKIE_NAME } from "../middleware/auth.js"; + +const loginSchema = z.object({ + username: z.string().min(1), + password: z.string().min(1) +}); + +export const authRouter = Router(); + +authRouter.post( + "/login", + asyncHandler(async (req, res) => { + const credentials = loginSchema.parse(req.body); + const user = statements.getAdminUserByUsername.get(credentials.username); + + if (!user) { + res.status(401).json({ error: "Invalid username or password" }); + return; + } + + const isValid = await argon2.verify( + user.password_hash, + credentials.password + ); + + if (!isValid) { + res.status(401).json({ error: "Invalid username or password" }); + return; + } + + req.session.userId = user.id; + req.session.username = user.username; + + res.status(200).json({ username: user.username }); + }) +); + +authRouter.post( + "/logout", + requireAuth, + asyncHandler(async (req, res) => { + await new Promise((resolve, reject) => { + req.session.destroy((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + + res.clearCookie(SESSION_COOKIE_NAME); + res.status(200).json({ success: true }); + }) +); + +authRouter.get( + "/me", + requireAuth, + asyncHandler(async (req, res) => { + res.status(200).json({ username: req.session.username }); + }) +); diff --git a/api/src/routes/dashboard.ts b/api/src/routes/dashboard.ts new file mode 100644 index 0000000..da6a1de --- /dev/null +++ b/api/src/routes/dashboard.ts @@ -0,0 +1,34 @@ +import { Router } from "express"; + +import { users } from "../db/mongo.js"; +import { statements } from "../db/sqlite.js"; +import { asyncHandler } from "../lib/async-handler.js"; +import { USER_FLAG_BANNED } from "../lib/flags.js"; + +export const dashboardRouter = Router(); + +dashboardRouter.get( + "/stats", + asyncHandler(async (_req, res) => { + const [totalUsers, bannedUserAggregate] = await Promise.all([ + users().countDocuments({}), + users() + .aggregate([ + { $match: { flags: { $bitsAllSet: USER_FLAG_BANNED } } }, + { $count: "count" } + ]) + .toArray() + ]); + + const pendingInvites = statements.countPendingInvites.get()?.count ?? 0; + const recentBans = statements.countRecentBans.get()?.count ?? 0; + const bannedUsers = bannedUserAggregate[0]?.count ?? 0; + + res.status(200).json({ + totalUsers, + bannedUsers, + pendingInvites, + recentBans + }); + }) +); diff --git a/api/src/routes/invites.ts b/api/src/routes/invites.ts new file mode 100644 index 0000000..56f2c01 --- /dev/null +++ b/api/src/routes/invites.ts @@ -0,0 +1,116 @@ +import { Router } from "express"; +import { customAlphabet } from "nanoid"; +import { Resend } from "resend"; +import { z } from "zod"; + +import { logAction } from "../db/audit.js"; +import { invites } from "../db/mongo.js"; +import { statements } from "../db/sqlite.js"; +import type { InviteRecord } from "../db/types.js"; +import { asyncHandler } from "../lib/async-handler.js"; +import { env } from "../lib/env.js"; + +const inviteAlphabet = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +const generateCode = customAlphabet(inviteAlphabet, 12); +const resend = new Resend(env.RESEND_API_KEY); + +const createInviteSchema = z.object({ + email: z.string().email(), + expiresInHours: z.coerce + .number() + .int() + .positive() + .max(24 * 365) + .optional() +}); + +function getInviteRecordOrThrow(code: string): InviteRecord { + const record = statements.getInviteRecordByCode.get(code); + if (!record) { + throw new Error(`Invite record ${code} not found after insert`); + } + + return record; +} + +export const invitesRouter = Router(); + +invitesRouter.get( + "/", + asyncHandler(async (_req, res) => { + const inviteRecords = statements.listInviteRecords.all(); + const count = + statements.countInviteRecords.get()?.count ?? inviteRecords.length; + + res.status(200).json({ invites: inviteRecords, count }); + }) +); + +invitesRouter.post( + "/", + asyncHandler(async (req, res) => { + const payload = createInviteSchema.parse(req.body); + const code = generateCode(); + const expiresAt = payload.expiresInHours + ? new Date( + Date.now() + payload.expiresInHours * 60 * 60 * 1000 + ).toISOString() + : null; + + await invites().insertOne({ _id: code }); + statements.insertInviteRecord.run(code, payload.email, expiresAt); + + let warning: string | undefined; + + try { + const response = await resend.emails.send({ + from: env.RESEND_FROM_EMAIL, + to: payload.email, + subject: `You've been invited to ${env.INSTANCE_NAME}`, + text: `You've been invited to ${env.INSTANCE_NAME}.\n\nUse this invite link to register:\n${env.INSTANCE_URL}?invite=${code}` + }); + + const messageId = response.data?.id ?? null; + statements.updateInviteResendMessage.run(messageId, code); + } catch (error) { + console.error("Invite email delivery failed", error); + warning = "Invite created but email delivery failed"; + } + + logAction("invite_created", payload.email, { + code, + expires_at: expiresAt + }); + + const record = getInviteRecordOrThrow(code); + res.status(201).json({ + invite: record, + ...(warning ? { warning } : {}) + }); + }) +); + +invitesRouter.delete( + "/:code", + asyncHandler(async (req, res) => { + const { code } = z.object({ code: z.string().min(1) }).parse(req.params); + const record = statements.getInviteRecordByCode.get(code); + + if (!record) { + res.status(404).json({ error: "Invite not found" }); + return; + } + + if (record.status !== "pending") { + res.status(400).json({ error: "Only pending invites can be revoked" }); + return; + } + + await invites().deleteOne({ _id: code }); + statements.markInviteRevoked.run(code); + logAction("invite_revoked", record.email, { code }); + + res.status(200).json({ success: true }); + }) +); diff --git a/api/src/routes/users.ts b/api/src/routes/users.ts new file mode 100644 index 0000000..ec12307 --- /dev/null +++ b/api/src/routes/users.ts @@ -0,0 +1,241 @@ +import { Router } from "express"; +import { ulid } from "ulid"; +import { z } from "zod"; + +import { logAction } from "../db/audit.js"; +import { accounts, safetyStrikes, sessions, users } from "../db/mongo.js"; +import { asyncHandler } from "../lib/async-handler.js"; +import { USER_FLAG_BANNED, USER_FLAG_DELETED } from "../lib/flags.js"; + +const listUsersSchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(50), + search: z.string().trim().optional() +}); + +const userIdParamsSchema = z.object({ + id: z.string().min(1) +}); + +const banSchema = z.object({ + reason: z.string().trim().min(1) +}); + +const deleteSchema = z.object({ + reason: z.string().trim().optional() +}); + +export const usersRouter = Router(); + +usersRouter.get( + "/", + asyncHandler(async (req, res) => { + const { page, limit, search } = listUsersSchema.parse(req.query); + + const basePipeline = [ + { + $lookup: { + from: "accounts", + localField: "_id", + foreignField: "_id", + as: "account", + pipeline: [ + { + $project: { + email: 1, + disabled: 1, + verification: 1, + deletion: 1 + } + } + ] + } + }, + { + $unwind: { + path: "$account", + preserveNullAndEmptyArrays: true + } + } + ]; + + const searchStage = search + ? [ + { + $match: { + "account.email": { + $regex: search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), + $options: "i" + } + } + } + ] + : []; + + const recordsPipeline = [ + ...basePipeline, + ...searchStage, + { $sort: { username: 1, discriminator: 1, _id: 1 } }, + { $skip: (page - 1) * limit }, + { $limit: limit }, + { + $project: { + _id: 1, + username: 1, + discriminator: 1, + flags: 1, + avatar: 1, + account: 1 + } + } + ]; + + const totalPipeline = [ + ...basePipeline, + ...searchStage, + { $count: "total" } + ]; + + const [userRecords, totalResult] = await Promise.all([ + users().aggregate(recordsPipeline).toArray(), + users().aggregate(totalPipeline).toArray() + ]); + + res.status(200).json({ + users: userRecords, + total: totalResult[0]?.total ?? 0, + page, + limit + }); + }) +); + +usersRouter.get( + "/:id", + asyncHandler(async (req, res) => { + const { id } = userIdParamsSchema.parse(req.params); + + const [user, account, strikes] = await Promise.all([ + users().findOne({ _id: id }), + accounts().findOne({ _id: id }), + safetyStrikes().find({ user_id: id }).sort({ _id: -1 }).toArray() + ]); + + if (!user && !account) { + res.status(404).json({ error: "User not found" }); + return; + } + + res.status(200).json({ user, account, strikes }); + }) +); + +usersRouter.post( + "/:id/ban", + asyncHandler(async (req, res) => { + const { id } = userIdParamsSchema.parse(req.params); + const { reason } = banSchema.parse(req.body); + + const [user, account] = await Promise.all([ + users().findOne({ _id: id }), + accounts().findOne({ _id: id }) + ]); + + if (!user || !account) { + res.status(404).json({ error: "User not found" }); + return; + } + + if (account.disabled) { + res.status(400).json({ error: "User is already banned" }); + return; + } + + await Promise.all([ + accounts().updateOne({ _id: id }, { $set: { disabled: true } }), + users().updateOne( + { _id: id }, + { $set: { flags: (user.flags ?? 0) | USER_FLAG_BANNED } } + ), + sessions().deleteMany({ user_id: id }), + safetyStrikes().insertOne({ + _id: ulid(), + user_id: id, + reason, + type: "ban" + }) + ]); + + logAction("user_banned", id, { reason }); + res.status(200).json({ success: true }); + }) +); + +usersRouter.post( + "/:id/unban", + asyncHandler(async (req, res) => { + const { id } = userIdParamsSchema.parse(req.params); + + const [user, account] = await Promise.all([ + users().findOne({ _id: id }), + accounts().findOne({ _id: id }) + ]); + + if (!account || !user) { + res.status(404).json({ error: "User not found" }); + return; + } + + if (!account.disabled) { + res.status(400).json({ error: "User is not banned" }); + return; + } + + await Promise.all([ + accounts().updateOne({ _id: id }, { $set: { disabled: false } }), + users().updateOne( + { _id: id }, + { $set: { flags: (user.flags ?? 0) & ~USER_FLAG_BANNED } } + ) + ]); + + logAction("user_unbanned", id); + res.status(200).json({ success: true }); + }) +); + +usersRouter.delete( + "/:id", + asyncHandler(async (req, res) => { + const { id } = userIdParamsSchema.parse(req.params); + const { reason } = deleteSchema.parse(req.body ?? {}); + const user = await users().findOne({ _id: id }); + + if (!user) { + res.status(404).json({ error: "User not found" }); + return; + } + + await Promise.all([ + accounts().updateOne( + { _id: id }, + { + $set: { + deletion: { + status: "Scheduled", + after: new Date().toISOString() + } + } + } + ), + users().updateOne( + { _id: id }, + { $set: { flags: (user.flags ?? 0) | USER_FLAG_DELETED } } + ), + sessions().deleteMany({ user_id: id }) + ]); + + logAction("user_deleted", id, reason ? { reason } : undefined); + res.status(200).json({ success: true }); + }) +); diff --git a/api/src/seed.ts b/api/src/seed.ts new file mode 100644 index 0000000..b22aba1 --- /dev/null +++ b/api/src/seed.ts @@ -0,0 +1,96 @@ +import "dotenv/config"; + +import argon2 from "argon2"; +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; +import { parseArgs } from "node:util"; + +import { statements } from "./db/sqlite.js"; + +type SeedArgs = { + username?: string; + password?: string; + "reset-password"?: boolean; +}; + +async function promptForMissing( + args: SeedArgs +): Promise<{ username: string; password: string }> { + const readline = createInterface({ input, output }); + + try { + const username = args.username ?? (await readline.question("Username: ")); + const password = args.password ?? (await readline.question("Password: ")); + + return { + username: username.trim(), + password: password.trim() + }; + } finally { + readline.close(); + } +} + +async function main(): Promise { + const parsed = parseArgs({ + options: { + username: { + type: "string" + }, + password: { + type: "string" + }, + "reset-password": { + type: "boolean", + default: false + } + } + }); + + const args = parsed.values as SeedArgs; + const existingUser = statements.getFirstAdminUser.get(); + + if (existingUser && !args["reset-password"]) { + console.error( + "An admin user already exists. Use --reset-password to update it." + ); + process.exit(1); + } + + const { username, password } = await promptForMissing({ + username: args.username ?? existingUser?.username, + password: args.password, + "reset-password": args["reset-password"] + }); + + if (!username || !password) { + console.error("Username and password are required."); + process.exit(1); + } + + const passwordHash = await argon2.hash(password, { + type: argon2.argon2id + }); + + if (args["reset-password"]) { + if (!existingUser) { + console.error( + "No admin user exists yet. Run the seed script without --reset-password first." + ); + process.exit(1); + } + + const targetUsername = existingUser?.username ?? username; + statements.updateAdminPasswordByUsername.run(passwordHash, targetUsername); + console.log(`Password updated for ${targetUsername}.`); + return; + } + + statements.insertAdminUser.run(username, passwordHash); + console.log(`Admin user ${username} created.`); +} + +void main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/api/src/types/better-sqlite3-session-store.d.ts b/api/src/types/better-sqlite3-session-store.d.ts new file mode 100644 index 0000000..e49228f --- /dev/null +++ b/api/src/types/better-sqlite3-session-store.d.ts @@ -0,0 +1,19 @@ +declare module "better-sqlite3-session-store" { + import type session from "express-session"; + + interface SqliteStoreOptions { + client: unknown; + expired?: { + clear?: boolean; + intervalMs?: number; + }; + } + + interface SqliteStoreConstructor { + new (options: SqliteStoreOptions): session.Store; + } + + export default function connectSqlite3( + sessionModule: typeof session + ): SqliteStoreConstructor; +} diff --git a/api/src/types/express-session.d.ts b/api/src/types/express-session.d.ts new file mode 100644 index 0000000..af98cb5 --- /dev/null +++ b/api/src/types/express-session.d.ts @@ -0,0 +1,10 @@ +import "express-session"; + +declare module "express-session" { + interface SessionData { + userId?: number; + username?: string; + } +} + +export {}; diff --git a/api/tsconfig.json b/api/tsconfig.json new file mode 100644 index 0000000..fefc733 --- /dev/null +++ b/api/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "rootDir": "./src", + "outDir": "./dist", + "types": ["node"] + }, + "include": ["src/**/*"] +} diff --git a/compose.override.example.yml b/compose.override.example.yml new file mode 100644 index 0000000..3659be5 --- /dev/null +++ b/compose.override.example.yml @@ -0,0 +1,20 @@ +services: + admin-api: + ports: + - "10.0.0.1:5181:5181" + environment: + ADMIN_API_PORT: 5181 + ADMIN_WEB_ORIGIN: http://10.0.0.1:5180 + + admin-web: + build: + args: + VITE_API_URL: http://10.0.0.1:5181 + ports: + - "10.0.0.1:5180:80" + +# Common customizations: +# - Change the external network name in compose.yml if your Stoat stack uses a different name. +# - Bind to your WireGuard interface IP instead of localhost. +# - Add resource limits or alternate image tags per deployment. + diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..fa017dc --- /dev/null +++ b/compose.yml @@ -0,0 +1,31 @@ +networks: + stoat: + external: true + name: stoat_default + +services: + admin-api: + build: + context: ./api + image: ghcr.io/OWNER/stoat-admin-api:latest + restart: unless-stopped + ports: + - "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_API_PORT:-5181}:5181" + env_file: + - .env + volumes: + - ./data:/data + networks: + - stoat + + admin-web: + build: + context: ./web + args: + VITE_API_URL: ${ADMIN_WEB_API_URL:-http://127.0.0.1:5181} + image: ghcr.io/OWNER/stoat-admin-web:latest + restart: unless-stopped + ports: + - "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_WEB_PORT:-5180}:80" + networks: + - stoat diff --git a/deploy/s6/s6-services.service b/deploy/s6/s6-services.service new file mode 100644 index 0000000..872b19f --- /dev/null +++ b/deploy/s6/s6-services.service @@ -0,0 +1,15 @@ +[Unit] +Description=s6 service supervision tree +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/s6-svscan /etc/s6-services +ExecStop=/usr/bin/s6-svscanctl -t /etc/s6-services +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target + diff --git a/deploy/s6/stoat-admin/finish b/deploy/s6/stoat-admin/finish new file mode 100755 index 0000000..c5675ac --- /dev/null +++ b/deploy/s6/stoat-admin/finish @@ -0,0 +1,4 @@ +#!/bin/bash +cd /srv/stoat-admin +podman compose down + diff --git a/deploy/s6/stoat-admin/log/run b/deploy/s6/stoat-admin/log/run new file mode 100755 index 0000000..1fe2507 --- /dev/null +++ b/deploy/s6/stoat-admin/log/run @@ -0,0 +1,3 @@ +#!/bin/bash +exec s6-log -b -- T /var/log/s6/stoat-admin/ + diff --git a/deploy/s6/stoat-admin/run b/deploy/s6/stoat-admin/run new file mode 100755 index 0000000..af7a753 --- /dev/null +++ b/deploy/s6/stoat-admin/run @@ -0,0 +1,11 @@ +#!/bin/bash +set -e + +if ! podman network exists stoat_default; then + sleep 5 + exit 1 +fi + +cd /srv/stoat-admin +exec podman compose up 2>&1 + diff --git a/deploy/s6/stoat/finish b/deploy/s6/stoat/finish new file mode 100755 index 0000000..c6f5f7c --- /dev/null +++ b/deploy/s6/stoat/finish @@ -0,0 +1,4 @@ +#!/bin/bash +cd /srv/stoat +podman compose down + diff --git a/deploy/s6/stoat/log/run b/deploy/s6/stoat/log/run new file mode 100755 index 0000000..fb33e52 --- /dev/null +++ b/deploy/s6/stoat/log/run @@ -0,0 +1,3 @@ +#!/bin/bash +exec s6-log -b -- T /var/log/s6/stoat/ + diff --git a/deploy/s6/stoat/run b/deploy/s6/stoat/run new file mode 100755 index 0000000..aecf46a --- /dev/null +++ b/deploy/s6/stoat/run @@ -0,0 +1,5 @@ +#!/bin/bash +set -e +cd /srv/stoat +exec podman compose up 2>&1 + diff --git a/docs/stoat-admin-design.md b/docs/stoat-admin-design.md index 6485083..bec421b 100644 --- a/docs/stoat-admin-design.md +++ b/docs/stoat-admin-design.md @@ -49,10 +49,10 @@ A lightweight, self-hosted admin dashboard for managing user invites, bans, and ### Services -| Service | Stack | Port | Access | -|--------------|-------------------|-------|-------------------| -| `admin-web` | Vite + React | 5180 | WireGuard only | -| `admin-api` | Express + Node 22 | 5181 | WireGuard only | +| Service | Stack | Port | Access | +| ----------- | ----------------- | ---- | -------------- | +| `admin-web` | Vite + React | 5180 | WireGuard only | +| `admin-api` | Express + Node 22 | 5181 | WireGuard only | Both services run in their own Podman Compose stack but join the Stoat stack's Podman network (`stoat_default`) as an external network, giving them direct access to MongoDB and Redis. No new databases — `admin-api` connects to Stoat's existing MongoDB instance. @@ -102,13 +102,16 @@ Stoat's account records. Relevant fields for admin operations: ```typescript type Account = { - _id: string; // ULID, matches user._id + _id: string; // ULID, matches user._id email: string; email_normalised: string; - disabled: boolean; // ← set true to ban at account level + disabled: boolean; // ← set true to ban at account level spam: boolean; verification: { status: "Verified" | "Pending" | "Moving" }; - deletion?: { status: "Scheduled" | "WaitingForVerification" | "Deleted"; after?: string }; + deletion?: { + status: "Scheduled" | "WaitingForVerification" | "Deleted"; + after?: string; + }; lockout?: { attempts: number; expiry: string }; }; ``` @@ -119,10 +122,10 @@ Stoat's user profiles. Relevant fields: ```typescript type User = { - _id: string; // ULID + _id: string; // ULID username: string; discriminator: string; - flags?: number; // bitmask: 1=suspended, 2=deleted, 4=banned + flags?: number; // bitmask: 1=suspended, 2=deleted, 4=banned // ... avatar, status, etc. }; ``` @@ -144,7 +147,7 @@ Used when `invite_only = true` in `Revolt.toml`. Each document is an invite code ```typescript type Invite = { - _id: string; // the invite code itself + _id: string; // the invite code itself }; ``` @@ -154,7 +157,7 @@ Strike/suspension/ban audit records (from the official admin panel schema). ```typescript type Strike = { - _id: string; // ULID + _id: string; // ULID user_id: string; reason: string; type?: "strike" | "suspension" | "ban"; @@ -217,19 +220,19 @@ All routes prefixed with `/api`. All require a valid session except `POST /api/a ### Auth -| Method | Path | Description | -|--------|-------------------|-------------------------------------| -| POST | `/api/auth/login` | Login with username + password | -| POST | `/api/auth/logout`| Destroy session | -| GET | `/api/auth/me` | Return current session user or 401 | +| Method | Path | Description | +| ------ | ------------------ | ---------------------------------- | +| POST | `/api/auth/login` | Login with username + password | +| POST | `/api/auth/logout` | Destroy session | +| GET | `/api/auth/me` | Return current session user or 401 | ### Invites -| Method | Path | Description | -|--------|------------------------|--------------------------------------------------------------| -| GET | `/api/invites` | List all invite records from SQLite (with status) | -| POST | `/api/invites` | Create invite: generate code → insert into Mongo + SQLite → send email via Resend | -| DELETE | `/api/invites/:code` | Revoke: delete from Mongo `revolt.invites`, set SQLite status to `revoked` | +| Method | Path | Description | +| ------ | -------------------- | --------------------------------------------------------------------------------- | +| GET | `/api/invites` | List all invite records from SQLite (with status) | +| POST | `/api/invites` | Create invite: generate code → insert into Mongo + SQLite → send email via Resend | +| DELETE | `/api/invites/:code` | Revoke: delete from Mongo `revolt.invites`, set SQLite status to `revoked` | #### Invite creation flow @@ -258,13 +261,13 @@ For each SQLite record where status = 'pending': ### Users -| Method | Path | Description | -|--------|------------------------------|---------------------------------------------------| -| GET | `/api/users` | List users from Mongo `revolt.users` (paginated) | -| GET | `/api/users/:id` | Get user + account details | -| POST | `/api/users/:id/ban` | Ban user (see flow below) | -| POST | `/api/users/:id/unban` | Reverse a ban | -| DELETE | `/api/users/:id` | Delete user (see flow below) | +| Method | Path | Description | +| ------ | ---------------------- | ------------------------------------------------ | +| GET | `/api/users` | List users from Mongo `revolt.users` (paginated) | +| GET | `/api/users/:id` | Get user + account details | +| POST | `/api/users/:id/ban` | Ban user (see flow below) | +| POST | `/api/users/:id/unban` | Reverse a ban | +| DELETE | `/api/users/:id` | Delete user (see flow below) | #### Ban flow @@ -330,7 +333,7 @@ Verify the Stoat network name with `podman network ls` while Stoat is running, a networks: stoat: external: true - name: stoat_default # must match the actual Stoat stack network name + name: stoat_default # must match the actual Stoat stack network name services: admin-api: @@ -394,6 +397,7 @@ systemd ### s6 Service Directories **`/etc/s6-services/stoat/run`:** + ```bash #!/bin/bash set -e @@ -402,6 +406,7 @@ exec podman compose up 2>&1 ``` **`/etc/s6-services/stoat/finish`:** + ```bash #!/bin/bash cd /srv/stoat @@ -409,6 +414,7 @@ podman compose down ``` **`/etc/s6-services/stoat-admin/run`:** + ```bash #!/bin/bash set -e @@ -427,6 +433,7 @@ exec podman compose up 2>&1 The admin stack's `run` script checks for the Stoat network before starting. If the network doesn't exist yet (because the Stoat stack hasn't finished initializing), the script sleeps briefly and exits. s6 restarts it automatically, effectively retrying until the network appears. Combined with the MongoDB connection retry in the admin-api code, this handles all timing dependencies without explicit dependency declarations. **`/etc/s6-services/stoat-admin/finish`:** + ```bash #!/bin/bash cd /srv/stoat-admin @@ -436,12 +443,14 @@ podman compose down **Log service (same pattern for both stacks):** **`/etc/s6-services/stoat/log/run`:** + ```bash #!/bin/bash exec s6-log -b -- T /var/log/s6/stoat/ ``` **`/etc/s6-services/stoat-admin/log/run`:** + ```bash #!/bin/bash exec s6-log -b -- T /var/log/s6/stoat-admin/ @@ -454,6 +463,7 @@ The `T` directive prefixes each log line with a TAI64N timestamp. Logs are writt A single systemd unit runs the s6 scan directory. This is the only systemd unit needed for the entire chat infrastructure. **`/etc/systemd/system/s6-services.service`:** + ```ini [Unit] Description=s6 service supervision tree @@ -496,6 +506,7 @@ tail -f /var/log/s6/stoat-admin/current | s6-tai64nlocal ### Dockerfiles **admin-api:** + ```dockerfile FROM node:22-slim WORKDIR /app @@ -508,6 +519,7 @@ CMD ["node", "dist/index.js"] ``` **admin-web:** + ```dockerfile FROM node:22-slim AS build-app WORKDIR /app diff --git a/docs/stoat-admin-tasks.md b/docs/stoat-admin-tasks.md index a70599d..8698b54 100644 --- a/docs/stoat-admin-tasks.md +++ b/docs/stoat-admin-tasks.md @@ -114,11 +114,11 @@ Create `api/src/db/mongo.ts`. Export a function `connectMongo()` that creates a ```typescript // Each function returns a typed Collection handle -accounts() // revolt.accounts -users() // revolt.users -sessions() // revolt.sessions -invites() // revolt.invites -safetyStrikes() // revolt.safety_strikes +accounts(); // revolt.accounts +users(); // revolt.users +sessions(); // revolt.sessions +invites(); // revolt.invites +safetyStrikes(); // revolt.safety_strikes ``` Define TypeScript interfaces for each collection's document shape matching the types in the design doc. Place these in `api/src/db/types.ts`. Only include the fields the admin dashboard reads or writes — do not attempt to type the entire Revolt schema. @@ -205,6 +205,7 @@ Create `api/src/routes/invites.ts`. Implement an Express Router. All routes requ `POST /api/invites`: Accept `{ email, expiresInHours?: number }` in the request body. Validate with Zod (email must be a valid email format). Implementation steps, in order: + 1. Generate a 12-character alphanumeric code using `nanoid` with a custom alphabet (`0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz`). 2. Compute `expires_at` as an ISO 8601 string if `expiresInHours` was provided, otherwise null. 3. Insert `{ _id: code }` into MongoDB `revolt.invites`. @@ -241,21 +242,21 @@ Create `api/src/routes/users.ts`. Implement an Express Router. All routes requir The join between `users` and `accounts` is by `_id` (they share the same ULID). Since MongoDB doesn't have native joins, perform this as a `$lookup` aggregation or two sequential queries. The aggregation approach is preferred: ```typescript -db.collection('users').aggregate([ +db.collection("users").aggregate([ { $match: matchFilter }, { $skip: (page - 1) * limit }, { $limit: limit }, { $lookup: { - from: 'accounts', - localField: '_id', - foreignField: '_id', - as: 'account', + from: "accounts", + localField: "_id", + foreignField: "_id", + as: "account", pipeline: [{ $project: { email: 1, disabled: 1, verification: 1 } }] } }, - { $unwind: { path: '$account', preserveNullAndEmptyArrays: true } } -]) + { $unwind: { path: "$account", preserveNullAndEmptyArrays: true } } +]); ``` `GET /api/users/:id`: Fetch a single user from `revolt.users` and their account from `revolt.accounts` by the same `_id`. Also fetch their strike history from `revolt.safety_strikes` where `user_id = id`. Return `200 { user, account, strikes }`. Return `404` if neither user nor account exists. @@ -263,6 +264,7 @@ db.collection('users').aggregate([ `POST /api/users/:id/ban`: Accept `{ reason: string }` in the request body. Validate with Zod (reason must be a non-empty string). Implementation steps, in order: + 1. Fetch the user from `revolt.users` to confirm they exist. Return `404` if not found. 2. Check if the account is already disabled (`revolt.accounts.disabled === true`). If so, return `400 { error: "User is already banned" }`. 3. Update `revolt.accounts`: set `disabled = true` where `_id = id`. @@ -275,6 +277,7 @@ Implementation steps, in order: `POST /api/users/:id/unban`: No request body required. Implementation steps: + 1. Fetch the account from `revolt.accounts`. Return `404` if not found. 2. Check that `disabled === true`. If not, return `400 { error: "User is not banned" }`. 3. Update `revolt.accounts`: set `disabled = false` where `_id = id`. @@ -285,6 +288,7 @@ Implementation steps: `DELETE /api/users/:id`: Accept optional `{ reason?: string }` in the request body. Implementation steps: + 1. Fetch the user from `revolt.users`. Return `404` if not found. 2. Update `revolt.accounts`: set `deletion = { status: "Scheduled", after: new Date().toISOString() }` where `_id = id`. 3. Update `revolt.users`: set `flags` to `(currentFlags || 0) | 2` where `_id = id`. @@ -299,6 +303,7 @@ Do not attempt to delete user data (messages, DMs, memberships) directly. Stoat' Create `api/src/routes/dashboard.ts`. Implement an Express Router. Requires auth. `GET /api/dashboard/stats`: Aggregate and return summary counts: + 1. Total users: `revolt.users.countDocuments({})`. 2. Banned users: `revolt.users.countDocuments({ flags: { $bitsAllSet: 4 } })`. 3. Pending invites: SQLite query `SELECT COUNT(*) FROM invite_records WHERE status = 'pending'`. @@ -315,6 +320,7 @@ Return `200 { totalUsers, bannedUsers, pendingInvites, recentBans }`. Create `api/src/index.ts`. This is the main entry point. Startup sequence: + 1. Load environment variables (use a validation function with Zod to parse and validate all required env vars at startup — fail fast with a clear error message if any are missing). 2. Connect to MongoDB via `connectMongo()`. 3. Initialize SQLite (the import of `sqlite.ts` triggers table creation). @@ -341,19 +347,22 @@ Create `web/src/lib/api.ts`. Export a configured fetch wrapper: ```typescript const API_BASE = import.meta.env.VITE_API_URL; -export async function apiFetch(path: string, options?: RequestInit): Promise { +export async function apiFetch( + path: string, + options?: RequestInit +): Promise { const res = await fetch(`${API_BASE}${path}`, { ...options, - credentials: 'include', // send session cookie + credentials: "include", // send session cookie headers: { - 'Content-Type': 'application/json', - ...options?.headers, - }, + "Content-Type": "application/json", + ...options?.headers + } }); if (!res.ok) { const body = await res.json().catch(() => ({})); - throw new ApiError(res.status, body.error || 'Request failed'); + throw new ApiError(res.status, body.error || "Request failed"); } return res.json(); @@ -537,7 +546,7 @@ Create `compose.yml` at the repo root. This is the generic, open-source-friendly networks: stoat: external: true - name: stoat_default # Adjust to match your Stoat stack's network name + name: stoat_default # Adjust to match your Stoat stack's network name services: admin-api: @@ -582,6 +591,7 @@ Create `compose.override.example.yml` with comments explaining common customizat Create example s6 service directory structures in a `deploy/s6/` directory at the repo root. These are reference files that users copy to their s6 scan directory (e.g., `/etc/s6-services/`). All `run` and `finish` scripts must be executable (`chmod +x`). **`deploy/s6/stoat-admin/run`:** + ```bash #!/bin/bash set -e @@ -601,6 +611,7 @@ exec podman compose up 2>&1 The network check handles the race condition where s6 starts both stacks simultaneously. If `stoat_default` doesn't exist yet, the script sleeps briefly and exits non-zero. s6 restarts it, and it tries again. Once the network exists, it falls through to `exec podman compose up` which replaces the bash process with the podman process — exactly what s6 expects as a long-lived supervised process. The `exec` is critical: without it, bash stays resident as a parent between s6 and podman, and signals from s6 would hit bash instead of podman. **`deploy/s6/stoat-admin/finish`:** + ```bash #!/bin/bash cd /srv/stoat-admin @@ -610,6 +621,7 @@ podman compose down The `finish` script runs whenever `run` exits (whether normally or via `s6-svc -d`). It ensures containers are cleaned up rather than left orphaned. No `exec` needed here — this is a short-lived cleanup script, not a long-running process. **`deploy/s6/stoat-admin/log/run`:** + ```bash #!/bin/bash exec s6-log -b -- T /var/log/s6/stoat-admin/ @@ -620,6 +632,7 @@ The `T` directive prefixes each line with a TAI64N timestamp. Logs for each stac Also create the equivalent Stoat stack service directory structure (`deploy/s6/stoat/`) with the same pattern, substituting the compose project path and removing the network check (the Stoat stack creates the network, it doesn't depend on it). Include both in the repo as reference examples, with a note that paths and network names must be adjusted for each deployment. **`deploy/s6/stoat/run`:** + ```bash #!/bin/bash set -e @@ -628,6 +641,7 @@ exec podman compose up 2>&1 ``` **`deploy/s6/stoat/finish`:** + ```bash #!/bin/bash cd /srv/stoat @@ -635,6 +649,7 @@ podman compose down ``` **`deploy/s6/stoat/log/run`:** + ```bash #!/bin/bash exec s6-log -b -- T /var/log/s6/stoat/ @@ -662,6 +677,7 @@ WantedBy=multi-user.target ``` Document the required setup steps in the README: + 1. Install s6 on Ubuntu 24.04: `apt install s6`. 2. Create the scan directory: `mkdir -p /etc/s6-services`. 3. Create log output directories: `mkdir -p /var/log/s6/stoat /var/log/s6/stoat-admin`. @@ -718,6 +734,7 @@ s6-svc -r /etc/s6-services/stoat-admin Create `.github/workflows/build.yml`. Trigger on push to `main` and on tags matching `v*`. Jobs: + 1. **build-api**: Check out the repo, set up Node 22, run `npm ci` and `npm run build` in `api/`, then build the Docker image and push to GHCR. Tag with both `latest` and the Git SHA (or Git tag if triggered by a tag push). 2. **build-web**: Same pattern for `web/`. Pass `VITE_API_URL` as a build arg — for the CI-built image, use a placeholder value. Users will rebuild with their own URL or override at runtime. @@ -728,6 +745,7 @@ Use `docker/login-action` for GHCR auth and `docker/build-push-action` for build Create `.github/workflows/ci.yml`. Trigger on pull requests and pushes to `main`. Jobs: + 1. **api-check**: Run `npm ci`, `npm run build` (TypeScript type checking), and `npx eslint .` in `api/`. 2. **web-check**: Run `npm ci`, `npm run build`, and `npx eslint .` in `web/`. diff --git a/package.json b/package.json new file mode 100644 index 0000000..27b93d8 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "stoat-admin", + "private": true, + "packageManager": "pnpm@10.6.3", + "license": "AGPL-3.0-only", + "devDependencies": { + "husky": "^9.1.7", + "lint-staged": "^16.4.0", + "prettier": "^3.8.1", + "turbo": "^2.5.4" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "argon2", + "better-sqlite3", + "esbuild" + ] + }, + "scripts": { + "prepare": "husky", + "dev": "turbo run dev --parallel", + "build": "turbo run build", + "lint": "turbo run lint", + "format": "prettier --write .", + "format:check": "prettier --check .", + "check": "pnpm format:check && turbo run lint build", + "dev:api": "turbo run dev --filter=stoat-admin-api", + "dev:web": "turbo run dev --filter=stoat-admin-web", + "seed": "pnpm --filter stoat-admin-api seed" + }, + "lint-staged": { + "*": "prettier --write --ignore-unknown" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..73e92b7 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,5300 @@ +lockfileVersion: "9.0" + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + .: + devDependencies: + husky: + specifier: ^9.1.7 + version: 9.1.7 + lint-staged: + specifier: ^16.4.0 + version: 16.4.0 + prettier: + specifier: ^3.8.1 + version: 3.8.1 + turbo: + specifier: ^2.5.4 + version: 2.9.1 + + api: + dependencies: + argon2: + specifier: ^0.44.0 + version: 0.44.0 + better-sqlite3: + specifier: ^12.8.0 + version: 12.8.0 + better-sqlite3-session-store: + specifier: ^0.1.0 + version: 0.1.0 + cors: + specifier: ^2.8.5 + version: 2.8.6 + dotenv: + specifier: ^17.3.1 + version: 17.3.1 + express: + specifier: ^5.2.1 + version: 5.2.1 + express-session: + specifier: ^1.18.1 + version: 1.19.0 + helmet: + specifier: ^8.0.0 + version: 8.1.0 + mongodb: + specifier: ^7.1.1 + version: 7.1.1 + nanoid: + specifier: ^5.1.5 + version: 5.1.7 + node-cron: + specifier: ^4.0.7 + version: 4.2.1 + resend: + specifier: ^6.10.0 + version: 6.10.0(@react-email/render@1.1.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + ulid: + specifier: ^3.0.2 + version: 3.0.2 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + "@eslint/js": + specifier: ^10.0.1 + version: 10.0.1(eslint@10.1.0(jiti@2.6.1)) + "@types/better-sqlite3": + specifier: ^7.6.12 + version: 7.6.13 + "@types/cors": + specifier: ^2.8.17 + version: 2.8.19 + "@types/express": + specifier: ^5.0.0 + version: 5.0.6 + "@types/express-session": + specifier: ^1.18.1 + version: 1.18.2 + "@types/node": + specifier: 24.12.0 + version: 24.12.0 + "@types/node-cron": + specifier: ^3.0.11 + version: 3.0.11 + "@typescript-eslint/eslint-plugin": + specifier: ^8.18.2 + version: 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + "@typescript-eslint/parser": + specifier: ^8.18.2 + version: 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + eslint: + specifier: ^10.1.0 + version: 10.1.0(jiti@2.6.1) + globals: + specifier: ^17.4.0 + version: 17.4.0 + tsx: + specifier: ^4.19.2 + version: 4.21.0 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + + web: + dependencies: + "@tanstack/react-query": + specifier: ^5.62.11 + version: 5.95.2(react@19.2.4) + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) + react-router-dom: + specifier: ^7.1.1 + version: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + ulid: + specifier: ^3.0.2 + version: 3.0.2 + devDependencies: + "@eslint/js": + specifier: ^10.0.1 + version: 10.0.1(eslint@10.1.0(jiti@2.6.1)) + "@tailwindcss/vite": + specifier: ^4.0.0 + version: 4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + "@types/node": + specifier: ^24.12.0 + version: 24.12.0 + "@types/react": + specifier: ^19.0.2 + version: 19.2.14 + "@types/react-dom": + specifier: ^19.0.2 + version: 19.2.3(@types/react@19.2.14) + "@typescript-eslint/eslint-plugin": + specifier: ^8.18.2 + version: 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + "@typescript-eslint/parser": + specifier: ^8.18.2 + version: 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + "@vitejs/plugin-react": + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + eslint: + specifier: ^10.1.0 + version: 10.1.0(jiti@2.6.1) + eslint-plugin-react-hooks: + specifier: ^7.0.1 + version: 7.0.1(eslint@10.1.0(jiti@2.6.1)) + eslint-plugin-react-refresh: + specifier: ^0.5.2 + version: 0.5.2(eslint@10.1.0(jiti@2.6.1)) + globals: + specifier: ^17.4.0 + version: 17.4.0 + tailwindcss: + specifier: ^4.0.0 + version: 4.2.2 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + vite: + specifier: ^8.0.3 + version: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + +packages: + "@babel/code-frame@7.29.0": + 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-string-parser@7.27.1": + resolution: + { + integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + } + engines: { node: ">=6.9.0" } + + "@babel/helper-validator-identifier@7.28.5": + 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.29.2": + resolution: + { + integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw== + } + engines: { node: ">=6.9.0" } + + "@babel/parser@7.29.2": + resolution: + { + integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA== + } + engines: { node: ">=6.0.0" } + hasBin: true + + "@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" } + + "@emnapi/core@1.9.1": + resolution: + { + integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA== + } + + "@emnapi/runtime@1.9.1": + resolution: + { + integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA== + } + + "@emnapi/wasi-threads@1.2.0": + resolution: + { + integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg== + } + + "@epic-web/invariant@1.0.0": + resolution: + { + integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA== + } + + "@esbuild/aix-ppc64@0.27.4": + resolution: + { + integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q== + } + engines: { node: ">=18" } + cpu: [ppc64] + os: [aix] + + "@esbuild/android-arm64@0.27.4": + resolution: + { + integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw== + } + engines: { node: ">=18" } + cpu: [arm64] + os: [android] + + "@esbuild/android-arm@0.27.4": + resolution: + { + integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ== + } + engines: { node: ">=18" } + cpu: [arm] + os: [android] + + "@esbuild/android-x64@0.27.4": + resolution: + { + integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw== + } + engines: { node: ">=18" } + cpu: [x64] + os: [android] + + "@esbuild/darwin-arm64@0.27.4": + resolution: + { + integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ== + } + engines: { node: ">=18" } + cpu: [arm64] + os: [darwin] + + "@esbuild/darwin-x64@0.27.4": + resolution: + { + integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw== + } + engines: { node: ">=18" } + cpu: [x64] + os: [darwin] + + "@esbuild/freebsd-arm64@0.27.4": + resolution: + { + integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw== + } + engines: { node: ">=18" } + cpu: [arm64] + os: [freebsd] + + "@esbuild/freebsd-x64@0.27.4": + resolution: + { + integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ== + } + engines: { node: ">=18" } + cpu: [x64] + os: [freebsd] + + "@esbuild/linux-arm64@0.27.4": + resolution: + { + integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA== + } + engines: { node: ">=18" } + cpu: [arm64] + os: [linux] + + "@esbuild/linux-arm@0.27.4": + resolution: + { + integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg== + } + engines: { node: ">=18" } + cpu: [arm] + os: [linux] + + "@esbuild/linux-ia32@0.27.4": + resolution: + { + integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA== + } + engines: { node: ">=18" } + cpu: [ia32] + os: [linux] + + "@esbuild/linux-loong64@0.27.4": + resolution: + { + integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA== + } + engines: { node: ">=18" } + cpu: [loong64] + os: [linux] + + "@esbuild/linux-mips64el@0.27.4": + resolution: + { + integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw== + } + engines: { node: ">=18" } + cpu: [mips64el] + os: [linux] + + "@esbuild/linux-ppc64@0.27.4": + resolution: + { + integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA== + } + engines: { node: ">=18" } + cpu: [ppc64] + os: [linux] + + "@esbuild/linux-riscv64@0.27.4": + resolution: + { + integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw== + } + engines: { node: ">=18" } + cpu: [riscv64] + os: [linux] + + "@esbuild/linux-s390x@0.27.4": + resolution: + { + integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA== + } + engines: { node: ">=18" } + cpu: [s390x] + os: [linux] + + "@esbuild/linux-x64@0.27.4": + resolution: + { + integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA== + } + engines: { node: ">=18" } + cpu: [x64] + os: [linux] + + "@esbuild/netbsd-arm64@0.27.4": + resolution: + { + integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q== + } + engines: { node: ">=18" } + cpu: [arm64] + os: [netbsd] + + "@esbuild/netbsd-x64@0.27.4": + resolution: + { + integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg== + } + engines: { node: ">=18" } + cpu: [x64] + os: [netbsd] + + "@esbuild/openbsd-arm64@0.27.4": + resolution: + { + integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow== + } + engines: { node: ">=18" } + cpu: [arm64] + os: [openbsd] + + "@esbuild/openbsd-x64@0.27.4": + resolution: + { + integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ== + } + engines: { node: ">=18" } + cpu: [x64] + os: [openbsd] + + "@esbuild/openharmony-arm64@0.27.4": + resolution: + { + integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg== + } + engines: { node: ">=18" } + cpu: [arm64] + os: [openharmony] + + "@esbuild/sunos-x64@0.27.4": + resolution: + { + integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g== + } + engines: { node: ">=18" } + cpu: [x64] + os: [sunos] + + "@esbuild/win32-arm64@0.27.4": + resolution: + { + integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg== + } + engines: { node: ">=18" } + cpu: [arm64] + os: [win32] + + "@esbuild/win32-ia32@0.27.4": + resolution: + { + integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw== + } + engines: { node: ">=18" } + cpu: [ia32] + os: [win32] + + "@esbuild/win32-x64@0.27.4": + resolution: + { + integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg== + } + engines: { node: ">=18" } + cpu: [x64] + os: [win32] + + "@eslint-community/eslint-utils@4.9.1": + resolution: + { + integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + "@eslint-community/regexpp@4.12.2": + resolution: + { + integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== + } + engines: { node: ^12.0.0 || ^14.0.0 || >=16.0.0 } + + "@eslint/config-array@0.23.3": + resolution: + { + integrity: sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw== + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + "@eslint/config-helpers@0.5.3": + resolution: + { + integrity: sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw== + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + "@eslint/core@1.1.1": + resolution: + { + integrity: sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ== + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + "@eslint/js@10.0.1": + resolution: + { + integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA== + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + "@eslint/object-schema@3.0.3": + resolution: + { + integrity: sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ== + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + "@eslint/plugin-kit@0.6.1": + resolution: + { + integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ== + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + "@humanfs/core@0.19.1": + resolution: + { + integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== + } + engines: { node: ">=18.18.0" } + + "@humanfs/node@0.16.7": + resolution: + { + integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ== + } + engines: { node: ">=18.18.0" } + + "@humanwhocodes/module-importer@1.0.1": + resolution: + { + integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + } + engines: { node: ">=12.22" } + + "@humanwhocodes/retry@0.4.3": + resolution: + { + integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== + } + engines: { node: ">=18.18" } + + "@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" } + + "@jridgewell/sourcemap-codec@1.5.5": + resolution: + { + integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + } + + "@jridgewell/trace-mapping@0.3.31": + resolution: + { + integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + } + + "@mongodb-js/saslprep@1.4.6": + resolution: + { + integrity: sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g== + } + + "@napi-rs/wasm-runtime@1.1.2": + resolution: + { + integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw== + } + peerDependencies: + "@emnapi/core": ^1.7.1 + "@emnapi/runtime": ^1.7.1 + + "@oxc-project/types@0.122.0": + resolution: + { + integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA== + } + + "@phc/format@1.0.0": + resolution: + { + integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ== + } + engines: { node: ">=10" } + + "@react-email/render@1.1.2": + resolution: + { + integrity: sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw== + } + engines: { node: ">=18.0.0" } + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^18.0 || ^19.0 || ^19.0.0-rc + + "@rolldown/binding-android-arm64@1.0.0-rc.12": + resolution: + { + integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA== + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [android] + + "@rolldown/binding-darwin-arm64@1.0.0-rc.12": + resolution: + { + integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg== + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [darwin] + + "@rolldown/binding-darwin-x64@1.0.0-rc.12": + resolution: + { + integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw== + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [darwin] + + "@rolldown/binding-freebsd-x64@1.0.0-rc.12": + resolution: + { + integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q== + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [freebsd] + + "@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12": + resolution: + { + integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q== + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm] + os: [linux] + + "@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12": + resolution: + { + integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg== + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [linux] + + "@rolldown/binding-linux-arm64-musl@1.0.0-rc.12": + resolution: + { + integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw== + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [linux] + + "@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12": + resolution: + { + integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g== + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [ppc64] + os: [linux] + + "@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12": + resolution: + { + integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og== + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [s390x] + os: [linux] + + "@rolldown/binding-linux-x64-gnu@1.0.0-rc.12": + resolution: + { + integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg== + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [linux] + + "@rolldown/binding-linux-x64-musl@1.0.0-rc.12": + resolution: + { + integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig== + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [linux] + + "@rolldown/binding-openharmony-arm64@1.0.0-rc.12": + resolution: + { + integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA== + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [openharmony] + + "@rolldown/binding-wasm32-wasi@1.0.0-rc.12": + resolution: + { + integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg== + } + engines: { node: ">=14.0.0" } + cpu: [wasm32] + + "@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12": + resolution: + { + integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q== + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [win32] + + "@rolldown/binding-win32-x64-msvc@1.0.0-rc.12": + resolution: + { + integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw== + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [win32] + + "@rolldown/pluginutils@1.0.0-rc.12": + resolution: + { + integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw== + } + + "@rolldown/pluginutils@1.0.0-rc.7": + resolution: + { + integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA== + } + + "@selderee/plugin-htmlparser2@0.11.0": + resolution: + { + integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ== + } + + "@stablelib/base64@1.0.1": + resolution: + { + integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ== + } + + "@tailwindcss/node@4.2.2": + resolution: + { + integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA== + } + + "@tailwindcss/oxide-android-arm64@4.2.2": + resolution: + { + integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg== + } + engines: { node: ">= 20" } + cpu: [arm64] + os: [android] + + "@tailwindcss/oxide-darwin-arm64@4.2.2": + resolution: + { + integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg== + } + engines: { node: ">= 20" } + cpu: [arm64] + os: [darwin] + + "@tailwindcss/oxide-darwin-x64@4.2.2": + resolution: + { + integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw== + } + engines: { node: ">= 20" } + cpu: [x64] + os: [darwin] + + "@tailwindcss/oxide-freebsd-x64@4.2.2": + resolution: + { + integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ== + } + engines: { node: ">= 20" } + cpu: [x64] + os: [freebsd] + + "@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2": + resolution: + { + integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ== + } + engines: { node: ">= 20" } + cpu: [arm] + os: [linux] + + "@tailwindcss/oxide-linux-arm64-gnu@4.2.2": + resolution: + { + integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw== + } + engines: { node: ">= 20" } + cpu: [arm64] + os: [linux] + + "@tailwindcss/oxide-linux-arm64-musl@4.2.2": + resolution: + { + integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag== + } + engines: { node: ">= 20" } + cpu: [arm64] + os: [linux] + + "@tailwindcss/oxide-linux-x64-gnu@4.2.2": + resolution: + { + integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg== + } + engines: { node: ">= 20" } + cpu: [x64] + os: [linux] + + "@tailwindcss/oxide-linux-x64-musl@4.2.2": + resolution: + { + integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ== + } + engines: { node: ">= 20" } + cpu: [x64] + os: [linux] + + "@tailwindcss/oxide-wasm32-wasi@4.2.2": + resolution: + { + integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q== + } + 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.2.2": + resolution: + { + integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ== + } + engines: { node: ">= 20" } + cpu: [arm64] + os: [win32] + + "@tailwindcss/oxide-win32-x64-msvc@4.2.2": + resolution: + { + integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA== + } + engines: { node: ">= 20" } + cpu: [x64] + os: [win32] + + "@tailwindcss/oxide@4.2.2": + resolution: + { + integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg== + } + engines: { node: ">= 20" } + + "@tailwindcss/vite@4.2.2": + resolution: + { + integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w== + } + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + "@tanstack/query-core@5.95.2": + resolution: + { + integrity: sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ== + } + + "@tanstack/react-query@5.95.2": + resolution: + { + integrity: sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA== + } + peerDependencies: + react: ^18 || ^19 + + "@turbo/darwin-64@2.9.1": + resolution: + { + integrity: sha512-d1zTcIf6VWT7cdfjhi0X36C2PRsUi2HdEwYzVgkLHmuuYtL+1Y1Zu3JdlouoB/NjG2vX3q4NnKLMNhDOEweoIg== + } + cpu: [x64] + os: [darwin] + + "@turbo/darwin-arm64@2.9.1": + resolution: + { + integrity: sha512-AwJ4mA++Kpem33Lcov093hS1LrgqbKxqq5FCReoqsA8ayEG6eAJAo8ItDd9qQTdBiXxZH8GHCspLAMIe1t3Xyw== + } + cpu: [arm64] + os: [darwin] + + "@turbo/linux-64@2.9.1": + resolution: + { + integrity: sha512-HT9SjKkjEw9uvlgly/qwCGEm4wOXOwQPSPS+wkg+/O1Qan3F1uU/0PFYzxl3m4lfuV3CP9wr2Dq5dPrUX+B9Ag== + } + cpu: [x64] + os: [linux] + + "@turbo/linux-arm64@2.9.1": + resolution: + { + integrity: sha512-+4s5GZs3kjxc1KMhLBhoQy4UBkXjOhgidA9ipNllkA4JLivSqUCuOgU1Xbyp6vzYrsqHJ9vvwo/2mXgEtD6ZHg== + } + cpu: [arm64] + os: [linux] + + "@turbo/windows-64@2.9.1": + resolution: + { + integrity: sha512-ZO7GCyQd5HV564XWHc9KysjanFfM3DmnWquyEByu+hQMq42g9OMU/fYOCfHS6Xj2aXkIg2FHJeRV+iAck2YrbQ== + } + cpu: [x64] + os: [win32] + + "@turbo/windows-arm64@2.9.1": + resolution: + { + integrity: sha512-BjX2fdz38mBb/H94JXrD5cJ+mEq8NmsCbYdC42JzQebJ0X8EdNgyFoEhOydPGViOmaRmhhdZnPZKKn6wahSpcA== + } + cpu: [arm64] + os: [win32] + + "@tybys/wasm-util@0.10.1": + resolution: + { + integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== + } + + "@types/better-sqlite3@7.6.13": + resolution: + { + integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA== + } + + "@types/body-parser@1.19.6": + resolution: + { + integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g== + } + + "@types/connect@3.4.38": + resolution: + { + integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== + } + + "@types/cors@2.8.19": + resolution: + { + integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg== + } + + "@types/esrecurse@4.3.1": + resolution: + { + integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw== + } + + "@types/estree@1.0.8": + resolution: + { + integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + } + + "@types/express-serve-static-core@5.1.1": + resolution: + { + integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A== + } + + "@types/express-session@1.18.2": + resolution: + { + integrity: sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg== + } + + "@types/express@5.0.6": + resolution: + { + integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA== + } + + "@types/http-errors@2.0.5": + resolution: + { + integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg== + } + + "@types/json-schema@7.0.15": + resolution: + { + integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + } + + "@types/node-cron@3.0.11": + resolution: + { + integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg== + } + + "@types/node@24.12.0": + resolution: + { + integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ== + } + + "@types/qs@6.15.0": + resolution: + { + integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow== + } + + "@types/range-parser@1.2.7": + resolution: + { + integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== + } + + "@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/send@1.2.1": + resolution: + { + integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ== + } + + "@types/serve-static@2.2.0": + resolution: + { + integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ== + } + + "@types/webidl-conversions@7.0.3": + resolution: + { + integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA== + } + + "@types/whatwg-url@13.0.0": + resolution: + { + integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q== + } + + "@typescript-eslint/eslint-plugin@8.58.0": + resolution: + { + integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg== + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + "@typescript-eslint/parser": ^8.58.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + + "@typescript-eslint/parser@8.58.0": + resolution: + { + integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA== + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + + "@typescript-eslint/project-service@8.58.0": + resolution: + { + integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg== + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + typescript: ">=4.8.4 <6.1.0" + + "@typescript-eslint/scope-manager@8.58.0": + resolution: + { + integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ== + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@typescript-eslint/tsconfig-utils@8.58.0": + resolution: + { + integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A== + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + typescript: ">=4.8.4 <6.1.0" + + "@typescript-eslint/type-utils@8.58.0": + resolution: + { + integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg== + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + + "@typescript-eslint/types@8.58.0": + resolution: + { + integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww== + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@typescript-eslint/typescript-estree@8.58.0": + resolution: + { + integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA== + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + typescript: ">=4.8.4 <6.1.0" + + "@typescript-eslint/utils@8.58.0": + resolution: + { + integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA== + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + + "@typescript-eslint/visitor-keys@8.58.0": + resolution: + { + integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ== + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@vitejs/plugin-react@6.0.1": + resolution: + { + integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ== + } + engines: { node: ^20.19.0 || >=22.12.0 } + peerDependencies: + "@rolldown/plugin-babel": ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + "@rolldown/plugin-babel": + optional: true + babel-plugin-react-compiler: + optional: true + + accepts@2.0.0: + resolution: + { + integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== + } + engines: { node: ">= 0.6" } + + acorn-jsx@5.3.2: + resolution: + { + integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + } + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: + { + integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== + } + engines: { node: ">=0.4.0" } + hasBin: true + + ajv@6.14.0: + resolution: + { + integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw== + } + + ansi-escapes@7.3.0: + resolution: + { + integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg== + } + engines: { node: ">=18" } + + ansi-regex@6.2.2: + resolution: + { + integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + } + engines: { node: ">=12" } + + ansi-styles@6.2.3: + resolution: + { + integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + } + engines: { node: ">=12" } + + argon2@0.44.0: + resolution: + { + integrity: sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig== + } + engines: { node: ">=16.17.0" } + + balanced-match@4.0.4: + resolution: + { + integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== + } + engines: { node: 18 || 20 || >=22 } + + base64-js@1.5.1: + resolution: + { + integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + } + + baseline-browser-mapping@2.10.12: + resolution: + { + integrity: sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ== + } + engines: { node: ">=6.0.0" } + hasBin: true + + better-sqlite3-session-store@0.1.0: + resolution: + { + integrity: sha512-O4EO5jOGTEa/c1DbZpP3C7VTDLSWe5lrOu1S/j86ipdGZxrSb8bSUVuRgWCgl/SCgEGmyeEqvlMY9HtyOSMOWA== + } + + better-sqlite3@12.8.0: + resolution: + { + integrity: sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ== + } + engines: { node: 20.x || 22.x || 23.x || 24.x || 25.x } + + bindings@1.5.0: + resolution: + { + integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + } + + bl@4.1.0: + resolution: + { + integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + } + + body-parser@2.2.2: + resolution: + { + integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA== + } + engines: { node: ">=18" } + + brace-expansion@5.0.5: + resolution: + { + integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ== + } + engines: { node: 18 || 20 || >=22 } + + browserslist@4.28.1: + resolution: + { + integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== + } + engines: { node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 } + hasBin: true + + bson@7.2.0: + resolution: + { + integrity: sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ== + } + engines: { node: ">=20.19.0" } + + buffer@5.7.1: + resolution: + { + integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + } + + bytes@3.1.2: + resolution: + { + integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + } + engines: { node: ">= 0.8" } + + call-bind-apply-helpers@1.0.2: + resolution: + { + integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + } + engines: { node: ">= 0.4" } + + call-bound@1.0.4: + resolution: + { + integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + } + engines: { node: ">= 0.4" } + + caniuse-lite@1.0.30001782: + resolution: + { + integrity: sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw== + } + + chownr@1.1.4: + resolution: + { + integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== + } + + cli-cursor@5.0.0: + resolution: + { + integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw== + } + engines: { node: ">=18" } + + cli-truncate@5.2.0: + resolution: + { + integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw== + } + engines: { node: ">=20" } + + colorette@2.0.20: + resolution: + { + integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== + } + + commander@14.0.3: + resolution: + { + integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw== + } + engines: { node: ">=20" } + + content-disposition@1.0.1: + resolution: + { + integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q== + } + engines: { node: ">=18" } + + content-type@1.0.5: + resolution: + { + integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + } + engines: { node: ">= 0.6" } + + convert-source-map@2.0.0: + resolution: + { + integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + } + + cookie-signature@1.0.7: + resolution: + { + integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA== + } + + cookie-signature@1.2.2: + resolution: + { + integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== + } + engines: { node: ">=6.6.0" } + + cookie@0.7.2: + resolution: + { + integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== + } + engines: { node: ">= 0.6" } + + cookie@1.1.1: + resolution: + { + integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ== + } + engines: { node: ">=18" } + + cors@2.8.6: + resolution: + { + integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw== + } + engines: { node: ">= 0.10" } + + cross-env@10.1.0: + resolution: + { + integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw== + } + engines: { node: ">=20" } + hasBin: true + + cross-spawn@7.0.6: + resolution: + { + integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + } + engines: { node: ">= 8" } + + csstype@3.2.3: + resolution: + { + integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + } + + date-fns@2.16.1: + resolution: + { + integrity: sha512-sAJVKx/FqrLYHAQeN7VpJrPhagZc9R4ImZIWYRFZaaohR3KzmuK88touwsSwSVT8Qcbd4zoDsnGfX4GFB4imyQ== + } + engines: { node: ">=0.11" } + + debug@2.6.9: + resolution: + { + integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + } + peerDependencies: + supports-color: "*" + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: + { + integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + } + engines: { node: ">=6.0" } + peerDependencies: + supports-color: "*" + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: + { + integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== + } + engines: { node: ">=10" } + + deep-extend@0.6.0: + resolution: + { + integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + } + engines: { node: ">=4.0.0" } + + deep-is@0.1.4: + resolution: + { + integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + } + + deepmerge@4.3.1: + resolution: + { + integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + } + engines: { node: ">=0.10.0" } + + depd@2.0.0: + resolution: + { + integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + } + engines: { node: ">= 0.8" } + + detect-libc@2.1.2: + resolution: + { + integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + } + engines: { node: ">=8" } + + dom-serializer@2.0.0: + resolution: + { + integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + } + + domelementtype@2.3.0: + resolution: + { + integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + } + + domhandler@5.0.3: + resolution: + { + integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + } + engines: { node: ">= 4" } + + domutils@3.2.2: + resolution: + { + integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== + } + + dotenv@17.3.1: + resolution: + { + integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA== + } + engines: { node: ">=12" } + + dunder-proto@1.0.1: + resolution: + { + integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + } + engines: { node: ">= 0.4" } + + ee-first@1.1.1: + resolution: + { + integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + } + + electron-to-chromium@1.5.328: + resolution: + { + integrity: sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w== + } + + emoji-regex@10.6.0: + resolution: + { + integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A== + } + + encodeurl@2.0.0: + resolution: + { + integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + } + engines: { node: ">= 0.8" } + + end-of-stream@1.4.5: + resolution: + { + integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg== + } + + enhanced-resolve@5.20.1: + resolution: + { + integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA== + } + engines: { node: ">=10.13.0" } + + entities@4.5.0: + resolution: + { + integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + } + engines: { node: ">=0.12" } + + environment@1.1.0: + resolution: + { + integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q== + } + engines: { node: ">=18" } + + es-define-property@1.0.1: + resolution: + { + integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + } + engines: { node: ">= 0.4" } + + es-errors@1.3.0: + resolution: + { + integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + } + engines: { node: ">= 0.4" } + + es-object-atoms@1.1.1: + resolution: + { + integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + } + engines: { node: ">= 0.4" } + + esbuild@0.27.4: + resolution: + { + integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ== + } + engines: { node: ">=18" } + hasBin: true + + escalade@3.2.0: + resolution: + { + integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + } + engines: { node: ">=6" } + + escape-html@1.0.3: + resolution: + { + integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + } + + escape-string-regexp@4.0.0: + resolution: + { + integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + } + engines: { node: ">=10" } + + eslint-plugin-react-hooks@7.0.1: + resolution: + { + integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA== + } + engines: { node: ">=18" } + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react-refresh@0.5.2: + resolution: + { + integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA== + } + peerDependencies: + eslint: ^9 || ^10 + + eslint-scope@9.1.2: + resolution: + { + integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ== + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + eslint-visitor-keys@3.4.3: + resolution: + { + integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } + + eslint-visitor-keys@5.0.1: + resolution: + { + integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + eslint@10.1.0: + resolution: + { + integrity: sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA== + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + hasBin: true + peerDependencies: + jiti: "*" + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: + { + integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw== + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + esquery@1.7.0: + resolution: + { + integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== + } + engines: { node: ">=0.10" } + + esrecurse@4.3.0: + resolution: + { + integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + } + engines: { node: ">=4.0" } + + estraverse@5.3.0: + resolution: + { + integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + } + engines: { node: ">=4.0" } + + esutils@2.0.3: + resolution: + { + integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + } + engines: { node: ">=0.10.0" } + + etag@1.8.1: + resolution: + { + integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + } + engines: { node: ">= 0.6" } + + eventemitter3@5.0.4: + resolution: + { + integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== + } + + expand-template@2.0.3: + resolution: + { + integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== + } + engines: { node: ">=6" } + + express-session@1.19.0: + resolution: + { + integrity: sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA== + } + engines: { node: ">= 0.8.0" } + + express@5.2.1: + resolution: + { + integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw== + } + engines: { node: ">= 18" } + + fast-deep-equal@2.0.1: + resolution: + { + integrity: sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w== + } + + fast-deep-equal@3.1.3: + resolution: + { + integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + } + + fast-json-stable-stringify@2.1.0: + resolution: + { + integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + } + + fast-levenshtein@2.0.6: + resolution: + { + integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + } + + fast-sha256@1.3.0: + resolution: + { + integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ== + } + + fdir@6.5.0: + resolution: + { + integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + } + engines: { node: ">=12.0.0" } + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: + { + integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== + } + engines: { node: ">=16.0.0" } + + file-uri-to-path@1.0.0: + resolution: + { + integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + } + + finalhandler@2.1.1: + resolution: + { + integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA== + } + engines: { node: ">= 18.0.0" } + + find-up@5.0.0: + resolution: + { + integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + } + engines: { node: ">=10" } + + flat-cache@4.0.1: + resolution: + { + integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== + } + engines: { node: ">=16" } + + flatted@3.4.2: + resolution: + { + integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== + } + + forwarded@0.2.0: + resolution: + { + integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + } + engines: { node: ">= 0.6" } + + fresh@2.0.0: + resolution: + { + integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== + } + engines: { node: ">= 0.8" } + + fs-constants@1.0.0: + resolution: + { + integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + } + + fsevents@2.3.3: + resolution: + { + integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + } + engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } + os: [darwin] + + function-bind@1.1.2: + resolution: + { + integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + } + + gensync@1.0.0-beta.2: + resolution: + { + integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + } + engines: { node: ">=6.9.0" } + + get-east-asian-width@1.5.0: + resolution: + { + integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA== + } + engines: { node: ">=18" } + + get-intrinsic@1.3.0: + resolution: + { + integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + } + engines: { node: ">= 0.4" } + + get-proto@1.0.1: + resolution: + { + integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + } + engines: { node: ">= 0.4" } + + get-tsconfig@4.13.7: + resolution: + { + integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q== + } + + github-from-package@0.0.0: + resolution: + { + integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw== + } + + glob-parent@6.0.2: + resolution: + { + integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + } + engines: { node: ">=10.13.0" } + + globals@17.4.0: + resolution: + { + integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw== + } + engines: { node: ">=18" } + + gopd@1.2.0: + resolution: + { + integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + } + engines: { node: ">= 0.4" } + + graceful-fs@4.2.11: + resolution: + { + integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + } + + has-symbols@1.1.0: + resolution: + { + integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + } + engines: { node: ">= 0.4" } + + hasown@2.0.2: + resolution: + { + integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + } + engines: { node: ">= 0.4" } + + helmet@8.1.0: + resolution: + { + integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg== + } + engines: { node: ">=18.0.0" } + + hermes-estree@0.25.1: + resolution: + { + integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw== + } + + hermes-parser@0.25.1: + resolution: + { + integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA== + } + + html-to-text@9.0.5: + resolution: + { + integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg== + } + engines: { node: ">=14" } + + htmlparser2@8.0.2: + resolution: + { + integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA== + } + + http-errors@2.0.1: + resolution: + { + integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== + } + engines: { node: ">= 0.8" } + + husky@9.1.7: + resolution: + { + integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA== + } + engines: { node: ">=18" } + hasBin: true + + iconv-lite@0.7.2: + resolution: + { + integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw== + } + engines: { node: ">=0.10.0" } + + ieee754@1.2.1: + resolution: + { + integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + } + + ignore@5.3.2: + resolution: + { + integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + } + engines: { node: ">= 4" } + + ignore@7.0.5: + resolution: + { + integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== + } + engines: { node: ">= 4" } + + imurmurhash@0.1.4: + resolution: + { + integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + } + engines: { node: ">=0.8.19" } + + inherits@2.0.4: + resolution: + { + integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + } + + ini@1.3.8: + resolution: + { + integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + } + + ipaddr.js@1.9.1: + resolution: + { + integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + } + engines: { node: ">= 0.10" } + + is-extglob@2.1.1: + resolution: + { + integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + } + engines: { node: ">=0.10.0" } + + is-fullwidth-code-point@5.1.0: + resolution: + { + integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ== + } + engines: { node: ">=18" } + + is-glob@4.0.3: + resolution: + { + integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + } + engines: { node: ">=0.10.0" } + + is-promise@4.0.0: + resolution: + { + integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== + } + + isexe@2.0.0: + resolution: + { + integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + } + + jiti@2.6.1: + resolution: + { + integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== + } + hasBin: true + + js-tokens@4.0.0: + resolution: + { + integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + } + + jsesc@3.1.0: + resolution: + { + integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== + } + engines: { node: ">=6" } + hasBin: true + + json-buffer@3.0.1: + resolution: + { + integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + } + + json-schema-traverse@0.4.1: + resolution: + { + integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + } + + json-stable-stringify-without-jsonify@1.0.1: + resolution: + { + integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + } + + json5@2.2.3: + resolution: + { + integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + } + engines: { node: ">=6" } + hasBin: true + + keyv@4.5.4: + resolution: + { + integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + } + + leac@0.6.0: + resolution: + { + integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg== + } + + levn@0.4.1: + resolution: + { + integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + } + engines: { node: ">= 0.8.0" } + + lightningcss-android-arm64@1.32.0: + resolution: + { + integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg== + } + engines: { node: ">= 12.0.0" } + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: + { + integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ== + } + engines: { node: ">= 12.0.0" } + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: + { + integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w== + } + engines: { node: ">= 12.0.0" } + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: + { + integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig== + } + engines: { node: ">= 12.0.0" } + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: + { + integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw== + } + engines: { node: ">= 12.0.0" } + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: + { + integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ== + } + engines: { node: ">= 12.0.0" } + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: + { + integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg== + } + engines: { node: ">= 12.0.0" } + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: + { + integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA== + } + engines: { node: ">= 12.0.0" } + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: + { + integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg== + } + engines: { node: ">= 12.0.0" } + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: + { + integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw== + } + engines: { node: ">= 12.0.0" } + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: + { + integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q== + } + engines: { node: ">= 12.0.0" } + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: + { + integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ== + } + engines: { node: ">= 12.0.0" } + + lint-staged@16.4.0: + resolution: + { + integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw== + } + engines: { node: ">=20.17" } + hasBin: true + + listr2@9.0.5: + resolution: + { + integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g== + } + engines: { node: ">=20.0.0" } + + locate-path@6.0.0: + resolution: + { + integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + } + engines: { node: ">=10" } + + log-update@6.1.0: + resolution: + { + integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w== + } + engines: { node: ">=18" } + + lru-cache@5.1.1: + resolution: + { + integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + } + + magic-string@0.30.21: + resolution: + { + integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + } + + math-intrinsics@1.1.0: + resolution: + { + integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + } + engines: { node: ">= 0.4" } + + media-typer@1.1.0: + resolution: + { + integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== + } + engines: { node: ">= 0.8" } + + memory-pager@1.5.0: + resolution: + { + integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg== + } + + merge-descriptors@2.0.0: + resolution: + { + integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== + } + engines: { node: ">=18" } + + mime-db@1.54.0: + resolution: + { + integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== + } + engines: { node: ">= 0.6" } + + mime-types@3.0.2: + resolution: + { + integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A== + } + engines: { node: ">=18" } + + mimic-function@5.0.1: + resolution: + { + integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA== + } + engines: { node: ">=18" } + + mimic-response@3.1.0: + resolution: + { + integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== + } + engines: { node: ">=10" } + + minimatch@10.2.5: + resolution: + { + integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== + } + engines: { node: 18 || 20 || >=22 } + + minimist@1.2.8: + resolution: + { + integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + } + + mkdirp-classic@0.5.3: + resolution: + { + integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== + } + + mongodb-connection-string-url@7.0.1: + resolution: + { + integrity: sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ== + } + engines: { node: ">=20.19.0" } + + mongodb@7.1.1: + resolution: + { + integrity: sha512-067DXiMjcpYQl6bGjWQoTUEE9UoRViTtKFcoqX7z08I+iDZv/emH1g8XEFiO3qiDfXAheT5ozl1VffDTKhIW/w== + } + engines: { node: ">=20.19.0" } + peerDependencies: + "@aws-sdk/credential-providers": ^3.806.0 + "@mongodb-js/zstd": ^7.0.0 + gcp-metadata: ^7.0.1 + kerberos: ^7.0.0 + mongodb-client-encryption: ">=7.0.0 <7.1.0" + snappy: ^7.3.2 + socks: ^2.8.6 + peerDependenciesMeta: + "@aws-sdk/credential-providers": + optional: true + "@mongodb-js/zstd": + optional: true + gcp-metadata: + optional: true + kerberos: + optional: true + mongodb-client-encryption: + optional: true + snappy: + optional: true + socks: + optional: true + + ms@2.0.0: + resolution: + { + integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + } + + ms@2.1.3: + resolution: + { + integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + } + + nanoid@3.3.11: + resolution: + { + integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + } + engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } + hasBin: true + + nanoid@5.1.7: + resolution: + { + integrity: sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ== + } + engines: { node: ^18 || >=20 } + hasBin: true + + napi-build-utils@2.0.0: + resolution: + { + integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA== + } + + natural-compare@1.4.0: + resolution: + { + integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + } + + negotiator@1.0.0: + resolution: + { + integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== + } + engines: { node: ">= 0.6" } + + node-abi@3.89.0: + resolution: + { + integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA== + } + engines: { node: ">=10" } + + node-addon-api@8.7.0: + resolution: + { + integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA== + } + engines: { node: ^18 || ^20 || >= 21 } + + node-cron@4.2.1: + resolution: + { + integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg== + } + engines: { node: ">=6.0.0" } + + node-gyp-build@4.8.4: + resolution: + { + integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ== + } + hasBin: true + + node-releases@2.0.36: + resolution: + { + integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA== + } + + object-assign@4.1.1: + resolution: + { + integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + } + engines: { node: ">=0.10.0" } + + object-inspect@1.13.4: + resolution: + { + integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + } + engines: { node: ">= 0.4" } + + on-finished@2.4.1: + resolution: + { + integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + } + engines: { node: ">= 0.8" } + + on-headers@1.1.0: + resolution: + { + integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A== + } + engines: { node: ">= 0.8" } + + once@1.4.0: + resolution: + { + integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + } + + onetime@7.0.0: + resolution: + { + integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ== + } + engines: { node: ">=18" } + + optionator@0.9.4: + resolution: + { + integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + } + engines: { node: ">= 0.8.0" } + + p-limit@3.1.0: + resolution: + { + integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + } + engines: { node: ">=10" } + + p-locate@5.0.0: + resolution: + { + integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + } + engines: { node: ">=10" } + + parseley@0.12.1: + resolution: + { + integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw== + } + + parseurl@1.3.3: + resolution: + { + integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + } + engines: { node: ">= 0.8" } + + path-exists@4.0.0: + resolution: + { + integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + } + engines: { node: ">=8" } + + path-key@3.1.1: + resolution: + { + integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + } + engines: { node: ">=8" } + + path-to-regexp@8.4.1: + resolution: + { + integrity: sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw== + } + + peberminta@0.9.0: + resolution: + { + integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ== + } + + picocolors@1.1.1: + resolution: + { + integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + } + + picomatch@4.0.4: + resolution: + { + integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== + } + engines: { node: ">=12" } + + postal-mime@2.7.4: + resolution: + { + integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g== + } + + postcss@8.5.8: + resolution: + { + integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg== + } + engines: { node: ^10 || ^12 || >=14 } + + prebuild-install@7.1.3: + resolution: + { + integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug== + } + engines: { node: ">=10" } + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + prelude-ls@1.2.1: + resolution: + { + integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + } + engines: { node: ">= 0.8.0" } + + prettier@3.8.1: + resolution: + { + integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== + } + engines: { node: ">=14" } + hasBin: true + + proxy-addr@2.0.7: + resolution: + { + integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + } + engines: { node: ">= 0.10" } + + pump@3.0.4: + resolution: + { + integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA== + } + + punycode@2.3.1: + resolution: + { + integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + } + engines: { node: ">=6" } + + qs@6.14.2: + resolution: + { + integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q== + } + engines: { node: ">=0.6" } + + random-bytes@1.0.0: + resolution: + { + integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ== + } + engines: { node: ">= 0.8" } + + range-parser@1.2.1: + resolution: + { + integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + } + engines: { node: ">= 0.6" } + + raw-body@3.0.2: + resolution: + { + integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA== + } + engines: { node: ">= 0.10" } + + 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-promise-suspense@0.3.4: + resolution: + { + integrity: sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ== + } + + react-router-dom@7.13.2: + resolution: + { + integrity: sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA== + } + engines: { node: ">=20.0.0" } + peerDependencies: + react: ">=18" + react-dom: ">=18" + + react-router@7.13.2: + resolution: + { + integrity: sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA== + } + engines: { node: ">=20.0.0" } + peerDependencies: + react: ">=18" + react-dom: ">=18" + peerDependenciesMeta: + react-dom: + 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" } + + resend@6.10.0: + resolution: + { + integrity: sha512-i7CwZpYj4Oho1RxsTpLcCUkO08+HiL4NXrm6jLJ2WzJ89UGI8eROSieLONJA3hnUrf1OYnCyfq5F6POnHUMv1Q== + } + engines: { node: ">=20" } + peerDependencies: + "@react-email/render": "*" + peerDependenciesMeta: + "@react-email/render": + optional: true + + resolve-pkg-maps@1.0.0: + resolution: + { + integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== + } + + restore-cursor@5.1.0: + resolution: + { + integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA== + } + engines: { node: ">=18" } + + rfdc@1.4.1: + resolution: + { + integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== + } + + rolldown@1.0.0-rc.12: + resolution: + { + integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A== + } + engines: { node: ^20.19.0 || >=22.12.0 } + hasBin: true + + router@2.2.0: + resolution: + { + integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ== + } + engines: { node: ">= 18" } + + safe-buffer@5.2.1: + resolution: + { + integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + } + + safer-buffer@2.1.2: + resolution: + { + integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + } + + scheduler@0.27.0: + resolution: + { + integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== + } + + selderee@0.11.0: + resolution: + { + integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA== + } + + 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 + + send@1.2.1: + resolution: + { + integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ== + } + engines: { node: ">= 18" } + + serve-static@2.2.1: + resolution: + { + integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw== + } + engines: { node: ">= 18" } + + set-cookie-parser@2.7.2: + resolution: + { + integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw== + } + + setprototypeof@1.2.0: + resolution: + { + integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + } + + shebang-command@2.0.0: + resolution: + { + integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + } + engines: { node: ">=8" } + + shebang-regex@3.0.0: + resolution: + { + integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + } + engines: { node: ">=8" } + + side-channel-list@1.0.0: + resolution: + { + integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== + } + engines: { node: ">= 0.4" } + + side-channel-map@1.0.1: + resolution: + { + integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + } + engines: { node: ">= 0.4" } + + side-channel-weakmap@1.0.2: + resolution: + { + integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + } + engines: { node: ">= 0.4" } + + side-channel@1.1.0: + resolution: + { + integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + } + engines: { node: ">= 0.4" } + + signal-exit@4.1.0: + resolution: + { + integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + } + engines: { node: ">=14" } + + simple-concat@1.0.1: + resolution: + { + integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== + } + + simple-get@4.0.1: + resolution: + { + integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA== + } + + slice-ansi@7.1.2: + resolution: + { + integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w== + } + engines: { node: ">=18" } + + slice-ansi@8.0.0: + resolution: + { + integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg== + } + engines: { node: ">=20" } + + source-map-js@1.2.1: + resolution: + { + integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + } + engines: { node: ">=0.10.0" } + + sparse-bitfield@3.0.3: + resolution: + { + integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ== + } + + standardwebhooks@1.0.0: + resolution: + { + integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg== + } + + statuses@2.0.2: + resolution: + { + integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== + } + engines: { node: ">= 0.8" } + + string-argv@0.3.2: + resolution: + { + integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== + } + engines: { node: ">=0.6.19" } + + string-width@7.2.0: + resolution: + { + integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ== + } + engines: { node: ">=18" } + + string-width@8.2.0: + resolution: + { + integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw== + } + engines: { node: ">=20" } + + string_decoder@1.3.0: + resolution: + { + integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + } + + strip-ansi@7.2.0: + resolution: + { + integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== + } + engines: { node: ">=12" } + + strip-json-comments@2.0.1: + resolution: + { + integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== + } + engines: { node: ">=0.10.0" } + + svix@1.88.0: + resolution: + { + integrity: sha512-vm/JrrUd3bVyBE+3L33TIyVSs8gS5fYx7lrISvKlDJXTYX1ACH4REX8P1tHxsSKoZi/rvifM1t0XRc5Vc45THw== + } + + tailwindcss@4.2.2: + resolution: + { + integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q== + } + + tapable@2.3.2: + resolution: + { + integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA== + } + engines: { node: ">=6" } + + tar-fs@2.1.4: + resolution: + { + integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ== + } + + tar-stream@2.2.0: + resolution: + { + integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== + } + engines: { node: ">=6" } + + tinyexec@1.0.4: + resolution: + { + integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw== + } + engines: { node: ">=18" } + + tinyglobby@0.2.15: + resolution: + { + integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + } + engines: { node: ">=12.0.0" } + + toidentifier@1.0.1: + resolution: + { + integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + } + engines: { node: ">=0.6" } + + tr46@5.1.1: + resolution: + { + integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw== + } + engines: { node: ">=18" } + + ts-api-utils@2.5.0: + resolution: + { + integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA== + } + engines: { node: ">=18.12" } + peerDependencies: + typescript: ">=4.8.4" + + tslib@2.8.1: + resolution: + { + integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + } + + tsx@4.21.0: + resolution: + { + integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw== + } + engines: { node: ">=18.0.0" } + hasBin: true + + tunnel-agent@0.6.0: + resolution: + { + integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w== + } + + turbo@2.9.1: + resolution: + { + integrity: sha512-TO9du8MwLTAKoXcGezekh9cPJabJUb0+8KxtpMR6kXdRASrmJ8qXf2GkVbCREgzbMQakzfNcux9cZtxheDY4RQ== + } + hasBin: true + + type-check@0.4.0: + resolution: + { + integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + } + engines: { node: ">= 0.8.0" } + + type-is@2.0.1: + resolution: + { + integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw== + } + engines: { node: ">= 0.6" } + + typescript@6.0.2: + resolution: + { + integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ== + } + engines: { node: ">=14.17" } + hasBin: true + + uid-safe@2.1.5: + resolution: + { + integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA== + } + engines: { node: ">= 0.8" } + + ulid@3.0.2: + resolution: + { + integrity: sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w== + } + hasBin: true + + undici-types@7.16.0: + resolution: + { + integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== + } + + unpipe@1.0.0: + resolution: + { + integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + } + engines: { node: ">= 0.8" } + + update-browserslist-db@1.2.3: + resolution: + { + integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w== + } + hasBin: true + peerDependencies: + browserslist: ">= 4.21.0" + + uri-js@4.4.1: + resolution: + { + integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + } + + util-deprecate@1.0.2: + resolution: + { + integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + } + + uuid@10.0.0: + resolution: + { + integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== + } + hasBin: true + + vary@1.1.2: + resolution: + { + integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + } + engines: { node: ">= 0.8" } + + vite@8.0.3: + resolution: + { + integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ== + } + engines: { node: ^20.19.0 || >=22.12.0 } + hasBin: true + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + "@vitejs/devtools": ^0.1.0 + esbuild: ^0.27.0 + jiti: ">=1.21.0" + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + "@types/node": + optional: true + "@vitejs/devtools": + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + webidl-conversions@7.0.0: + resolution: + { + integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== + } + engines: { node: ">=12" } + + whatwg-url@14.2.0: + resolution: + { + integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw== + } + engines: { node: ">=18" } + + which@2.0.2: + resolution: + { + integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + } + engines: { node: ">= 8" } + hasBin: true + + word-wrap@1.2.5: + resolution: + { + integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + } + engines: { node: ">=0.10.0" } + + wrap-ansi@9.0.2: + resolution: + { + integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww== + } + engines: { node: ">=18" } + + wrappy@1.0.2: + resolution: + { + integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + } + + yallist@3.1.1: + resolution: + { + integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== + } + + yaml@2.8.3: + resolution: + { + integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg== + } + engines: { node: ">= 14.6" } + hasBin: true + + yocto-queue@0.1.0: + resolution: + { + integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + } + engines: { node: ">=10" } + + zod-validation-error@4.0.2: + resolution: + { + integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ== + } + engines: { node: ">=18.0.0" } + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.3.6: + resolution: + { + integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg== + } + +snapshots: + "@babel/code-frame@7.29.0": + dependencies: + "@babel/helper-validator-identifier": 7.28.5 + 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.29.2 + "@babel/parser": 7.29.2 + "@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.2 + "@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-string-parser@7.27.1": {} + + "@babel/helper-validator-identifier@7.28.5": {} + + "@babel/helper-validator-option@7.27.1": {} + + "@babel/helpers@7.29.2": + dependencies: + "@babel/template": 7.28.6 + "@babel/types": 7.29.0 + + "@babel/parser@7.29.2": + dependencies: + "@babel/types": 7.29.0 + + "@babel/template@7.28.6": + dependencies: + "@babel/code-frame": 7.29.0 + "@babel/parser": 7.29.2 + "@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.2 + "@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 + "@babel/helper-validator-identifier": 7.28.5 + + "@emnapi/core@1.9.1": + dependencies: + "@emnapi/wasi-threads": 1.2.0 + tslib: 2.8.1 + optional: true + + "@emnapi/runtime@1.9.1": + dependencies: + tslib: 2.8.1 + optional: true + + "@emnapi/wasi-threads@1.2.0": + dependencies: + tslib: 2.8.1 + optional: true + + "@epic-web/invariant@1.0.0": {} + + "@esbuild/aix-ppc64@0.27.4": + optional: true + + "@esbuild/android-arm64@0.27.4": + optional: true + + "@esbuild/android-arm@0.27.4": + optional: true + + "@esbuild/android-x64@0.27.4": + optional: true + + "@esbuild/darwin-arm64@0.27.4": + optional: true + + "@esbuild/darwin-x64@0.27.4": + optional: true + + "@esbuild/freebsd-arm64@0.27.4": + optional: true + + "@esbuild/freebsd-x64@0.27.4": + optional: true + + "@esbuild/linux-arm64@0.27.4": + optional: true + + "@esbuild/linux-arm@0.27.4": + optional: true + + "@esbuild/linux-ia32@0.27.4": + optional: true + + "@esbuild/linux-loong64@0.27.4": + optional: true + + "@esbuild/linux-mips64el@0.27.4": + optional: true + + "@esbuild/linux-ppc64@0.27.4": + optional: true + + "@esbuild/linux-riscv64@0.27.4": + optional: true + + "@esbuild/linux-s390x@0.27.4": + optional: true + + "@esbuild/linux-x64@0.27.4": + optional: true + + "@esbuild/netbsd-arm64@0.27.4": + optional: true + + "@esbuild/netbsd-x64@0.27.4": + optional: true + + "@esbuild/openbsd-arm64@0.27.4": + optional: true + + "@esbuild/openbsd-x64@0.27.4": + optional: true + + "@esbuild/openharmony-arm64@0.27.4": + optional: true + + "@esbuild/sunos-x64@0.27.4": + optional: true + + "@esbuild/win32-arm64@0.27.4": + optional: true + + "@esbuild/win32-ia32@0.27.4": + optional: true + + "@esbuild/win32-x64@0.27.4": + optional: true + + "@eslint-community/eslint-utils@4.9.1(eslint@10.1.0(jiti@2.6.1))": + dependencies: + eslint: 10.1.0(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + "@eslint-community/regexpp@4.12.2": {} + + "@eslint/config-array@0.23.3": + dependencies: + "@eslint/object-schema": 3.0.3 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + "@eslint/config-helpers@0.5.3": + dependencies: + "@eslint/core": 1.1.1 + + "@eslint/core@1.1.1": + dependencies: + "@types/json-schema": 7.0.15 + + "@eslint/js@10.0.1(eslint@10.1.0(jiti@2.6.1))": + optionalDependencies: + eslint: 10.1.0(jiti@2.6.1) + + "@eslint/object-schema@3.0.3": {} + + "@eslint/plugin-kit@0.6.1": + dependencies: + "@eslint/core": 1.1.1 + levn: 0.4.1 + + "@humanfs/core@0.19.1": {} + + "@humanfs/node@0.16.7": + dependencies: + "@humanfs/core": 0.19.1 + "@humanwhocodes/retry": 0.4.3 + + "@humanwhocodes/module-importer@1.0.1": {} + + "@humanwhocodes/retry@0.4.3": {} + + "@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": {} + + "@jridgewell/trace-mapping@0.3.31": + dependencies: + "@jridgewell/resolve-uri": 3.1.2 + "@jridgewell/sourcemap-codec": 1.5.5 + + "@mongodb-js/saslprep@1.4.6": + dependencies: + sparse-bitfield: 3.0.3 + + "@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)": + dependencies: + "@emnapi/core": 1.9.1 + "@emnapi/runtime": 1.9.1 + "@tybys/wasm-util": 0.10.1 + optional: true + + "@oxc-project/types@0.122.0": {} + + "@phc/format@1.0.0": {} + + "@react-email/render@1.1.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)": + dependencies: + html-to-text: 9.0.5 + prettier: 3.8.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-promise-suspense: 0.3.4 + optional: true + + "@rolldown/binding-android-arm64@1.0.0-rc.12": + optional: true + + "@rolldown/binding-darwin-arm64@1.0.0-rc.12": + optional: true + + "@rolldown/binding-darwin-x64@1.0.0-rc.12": + optional: true + + "@rolldown/binding-freebsd-x64@1.0.0-rc.12": + optional: true + + "@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12": + optional: true + + "@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12": + optional: true + + "@rolldown/binding-linux-arm64-musl@1.0.0-rc.12": + optional: true + + "@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12": + optional: true + + "@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12": + optional: true + + "@rolldown/binding-linux-x64-gnu@1.0.0-rc.12": + optional: true + + "@rolldown/binding-linux-x64-musl@1.0.0-rc.12": + optional: true + + "@rolldown/binding-openharmony-arm64@1.0.0-rc.12": + optional: true + + "@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)": + dependencies: + "@napi-rs/wasm-runtime": 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + transitivePeerDependencies: + - "@emnapi/core" + - "@emnapi/runtime" + optional: true + + "@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12": + optional: true + + "@rolldown/binding-win32-x64-msvc@1.0.0-rc.12": + optional: true + + "@rolldown/pluginutils@1.0.0-rc.12": {} + + "@rolldown/pluginutils@1.0.0-rc.7": {} + + "@selderee/plugin-htmlparser2@0.11.0": + dependencies: + domhandler: 5.0.3 + selderee: 0.11.0 + optional: true + + "@stablelib/base64@1.0.1": {} + + "@tailwindcss/node@4.2.2": + dependencies: + "@jridgewell/remapping": 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.2 + + "@tailwindcss/oxide-android-arm64@4.2.2": + optional: true + + "@tailwindcss/oxide-darwin-arm64@4.2.2": + optional: true + + "@tailwindcss/oxide-darwin-x64@4.2.2": + optional: true + + "@tailwindcss/oxide-freebsd-x64@4.2.2": + optional: true + + "@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2": + optional: true + + "@tailwindcss/oxide-linux-arm64-gnu@4.2.2": + optional: true + + "@tailwindcss/oxide-linux-arm64-musl@4.2.2": + optional: true + + "@tailwindcss/oxide-linux-x64-gnu@4.2.2": + optional: true + + "@tailwindcss/oxide-linux-x64-musl@4.2.2": + optional: true + + "@tailwindcss/oxide-wasm32-wasi@4.2.2": + optional: true + + "@tailwindcss/oxide-win32-arm64-msvc@4.2.2": + optional: true + + "@tailwindcss/oxide-win32-x64-msvc@4.2.2": + optional: true + + "@tailwindcss/oxide@4.2.2": + optionalDependencies: + "@tailwindcss/oxide-android-arm64": 4.2.2 + "@tailwindcss/oxide-darwin-arm64": 4.2.2 + "@tailwindcss/oxide-darwin-x64": 4.2.2 + "@tailwindcss/oxide-freebsd-x64": 4.2.2 + "@tailwindcss/oxide-linux-arm-gnueabihf": 4.2.2 + "@tailwindcss/oxide-linux-arm64-gnu": 4.2.2 + "@tailwindcss/oxide-linux-arm64-musl": 4.2.2 + "@tailwindcss/oxide-linux-x64-gnu": 4.2.2 + "@tailwindcss/oxide-linux-x64-musl": 4.2.2 + "@tailwindcss/oxide-wasm32-wasi": 4.2.2 + "@tailwindcss/oxide-win32-arm64-msvc": 4.2.2 + "@tailwindcss/oxide-win32-x64-msvc": 4.2.2 + + "@tailwindcss/vite@4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))": + dependencies: + "@tailwindcss/node": 4.2.2 + "@tailwindcss/oxide": 4.2.2 + tailwindcss: 4.2.2 + vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + + "@tanstack/query-core@5.95.2": {} + + "@tanstack/react-query@5.95.2(react@19.2.4)": + dependencies: + "@tanstack/query-core": 5.95.2 + react: 19.2.4 + + "@turbo/darwin-64@2.9.1": + optional: true + + "@turbo/darwin-arm64@2.9.1": + optional: true + + "@turbo/linux-64@2.9.1": + optional: true + + "@turbo/linux-arm64@2.9.1": + optional: true + + "@turbo/windows-64@2.9.1": + optional: true + + "@turbo/windows-arm64@2.9.1": + optional: true + + "@tybys/wasm-util@0.10.1": + dependencies: + tslib: 2.8.1 + optional: true + + "@types/better-sqlite3@7.6.13": + dependencies: + "@types/node": 24.12.0 + + "@types/body-parser@1.19.6": + dependencies: + "@types/connect": 3.4.38 + "@types/node": 24.12.0 + + "@types/connect@3.4.38": + dependencies: + "@types/node": 24.12.0 + + "@types/cors@2.8.19": + dependencies: + "@types/node": 24.12.0 + + "@types/esrecurse@4.3.1": {} + + "@types/estree@1.0.8": {} + + "@types/express-serve-static-core@5.1.1": + dependencies: + "@types/node": 24.12.0 + "@types/qs": 6.15.0 + "@types/range-parser": 1.2.7 + "@types/send": 1.2.1 + + "@types/express-session@1.18.2": + dependencies: + "@types/express": 5.0.6 + + "@types/express@5.0.6": + dependencies: + "@types/body-parser": 1.19.6 + "@types/express-serve-static-core": 5.1.1 + "@types/serve-static": 2.2.0 + + "@types/http-errors@2.0.5": {} + + "@types/json-schema@7.0.15": {} + + "@types/node-cron@3.0.11": {} + + "@types/node@24.12.0": + dependencies: + undici-types: 7.16.0 + + "@types/qs@6.15.0": {} + + "@types/range-parser@1.2.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/send@1.2.1": + dependencies: + "@types/node": 24.12.0 + + "@types/serve-static@2.2.0": + dependencies: + "@types/http-errors": 2.0.5 + "@types/node": 24.12.0 + + "@types/webidl-conversions@7.0.3": {} + + "@types/whatwg-url@13.0.0": + dependencies: + "@types/webidl-conversions": 7.0.3 + + "@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)": + dependencies: + "@eslint-community/regexpp": 4.12.2 + "@typescript-eslint/parser": 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + "@typescript-eslint/scope-manager": 8.58.0 + "@typescript-eslint/type-utils": 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + "@typescript-eslint/utils": 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + "@typescript-eslint/visitor-keys": 8.58.0 + eslint: 10.1.0(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.2) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + "@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)": + dependencies: + "@typescript-eslint/scope-manager": 8.58.0 + "@typescript-eslint/types": 8.58.0 + "@typescript-eslint/typescript-estree": 8.58.0(typescript@6.0.2) + "@typescript-eslint/visitor-keys": 8.58.0 + debug: 4.4.3 + eslint: 10.1.0(jiti@2.6.1) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + "@typescript-eslint/project-service@8.58.0(typescript@6.0.2)": + dependencies: + "@typescript-eslint/tsconfig-utils": 8.58.0(typescript@6.0.2) + "@typescript-eslint/types": 8.58.0 + debug: 4.4.3 + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + "@typescript-eslint/scope-manager@8.58.0": + dependencies: + "@typescript-eslint/types": 8.58.0 + "@typescript-eslint/visitor-keys": 8.58.0 + + "@typescript-eslint/tsconfig-utils@8.58.0(typescript@6.0.2)": + dependencies: + typescript: 6.0.2 + + "@typescript-eslint/type-utils@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)": + dependencies: + "@typescript-eslint/types": 8.58.0 + "@typescript-eslint/typescript-estree": 8.58.0(typescript@6.0.2) + "@typescript-eslint/utils": 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + debug: 4.4.3 + eslint: 10.1.0(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@6.0.2) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + "@typescript-eslint/types@8.58.0": {} + + "@typescript-eslint/typescript-estree@8.58.0(typescript@6.0.2)": + dependencies: + "@typescript-eslint/project-service": 8.58.0(typescript@6.0.2) + "@typescript-eslint/tsconfig-utils": 8.58.0(typescript@6.0.2) + "@typescript-eslint/types": 8.58.0 + "@typescript-eslint/visitor-keys": 8.58.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@6.0.2) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + "@typescript-eslint/utils@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)": + dependencies: + "@eslint-community/eslint-utils": 4.9.1(eslint@10.1.0(jiti@2.6.1)) + "@typescript-eslint/scope-manager": 8.58.0 + "@typescript-eslint/types": 8.58.0 + "@typescript-eslint/typescript-estree": 8.58.0(typescript@6.0.2) + eslint: 10.1.0(jiti@2.6.1) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + "@typescript-eslint/visitor-keys@8.58.0": + dependencies: + "@typescript-eslint/types": 8.58.0 + eslint-visitor-keys: 5.0.1 + + "@vitejs/plugin-react@6.0.1(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))": + dependencies: + "@rolldown/pluginutils": 1.0.0-rc.7 + vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@6.2.2: {} + + ansi-styles@6.2.3: {} + + argon2@0.44.0: + dependencies: + "@phc/format": 1.0.0 + cross-env: 10.1.0 + node-addon-api: 8.7.0 + node-gyp-build: 4.8.4 + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.12: {} + + better-sqlite3-session-store@0.1.0: + dependencies: + date-fns: 2.16.1 + + better-sqlite3@12.8.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.14.2 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.12 + caniuse-lite: 1.0.30001782 + electron-to-chromium: 1.5.328 + node-releases: 2.0.36 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + bson@7.2.0: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caniuse-lite@1.0.30001782: {} + + chownr@1.1.4: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.0 + + colorette@2.0.20: {} + + commander@14.0.3: {} + + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.0.7: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-env@10.1.0: + dependencies: + "@epic-web/invariant": 1.0.0 + cross-spawn: 7.0.6 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + date-fns@2.16.1: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: + optional: true + + depd@2.0.0: {} + + detect-libc@2.1.2: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + optional: true + + domelementtype@2.3.0: + optional: true + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + optional: true + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + optional: true + + dotenv@17.3.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.328: {} + + emoji-regex@10.6.0: {} + + encodeurl@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.2 + + entities@4.5.0: + optional: true + + environment@1.1.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + esbuild@0.27.4: + optionalDependencies: + "@esbuild/aix-ppc64": 0.27.4 + "@esbuild/android-arm": 0.27.4 + "@esbuild/android-arm64": 0.27.4 + "@esbuild/android-x64": 0.27.4 + "@esbuild/darwin-arm64": 0.27.4 + "@esbuild/darwin-x64": 0.27.4 + "@esbuild/freebsd-arm64": 0.27.4 + "@esbuild/freebsd-x64": 0.27.4 + "@esbuild/linux-arm": 0.27.4 + "@esbuild/linux-arm64": 0.27.4 + "@esbuild/linux-ia32": 0.27.4 + "@esbuild/linux-loong64": 0.27.4 + "@esbuild/linux-mips64el": 0.27.4 + "@esbuild/linux-ppc64": 0.27.4 + "@esbuild/linux-riscv64": 0.27.4 + "@esbuild/linux-s390x": 0.27.4 + "@esbuild/linux-x64": 0.27.4 + "@esbuild/netbsd-arm64": 0.27.4 + "@esbuild/netbsd-x64": 0.27.4 + "@esbuild/openbsd-arm64": 0.27.4 + "@esbuild/openbsd-x64": 0.27.4 + "@esbuild/openharmony-arm64": 0.27.4 + "@esbuild/sunos-x64": 0.27.4 + "@esbuild/win32-arm64": 0.27.4 + "@esbuild/win32-ia32": 0.27.4 + "@esbuild/win32-x64": 0.27.4 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@7.0.1(eslint@10.1.0(jiti@2.6.1)): + dependencies: + "@babel/core": 7.29.0 + "@babel/parser": 7.29.2 + eslint: 10.1.0(jiti@2.6.1) + hermes-parser: 0.25.1 + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.5.2(eslint@10.1.0(jiti@2.6.1)): + dependencies: + eslint: 10.1.0(jiti@2.6.1) + + eslint-scope@9.1.2: + dependencies: + "@types/esrecurse": 4.3.1 + "@types/estree": 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.1.0(jiti@2.6.1): + dependencies: + "@eslint-community/eslint-utils": 4.9.1(eslint@10.1.0(jiti@2.6.1)) + "@eslint-community/regexpp": 4.12.2 + "@eslint/config-array": 0.23.3 + "@eslint/config-helpers": 0.5.3 + "@eslint/core": 1.1.1 + "@eslint/plugin-kit": 0.6.1 + "@humanfs/node": 0.16.7 + "@humanwhocodes/module-importer": 1.0.1 + "@humanwhocodes/retry": 0.4.3 + "@types/estree": 1.0.8 + ajv: 6.14.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + eventemitter3@5.0.4: {} + + expand-template@2.0.3: {} + + express-session@1.19.0: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + on-headers: 1.1.0 + parseurl: 1.3.3 + safe-buffer: 5.2.1 + uid-safe: 2.1.5 + transitivePeerDependencies: + - supports-color + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@2.0.1: + optional: true + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-sha256@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + file-uri-to-path@1.0.0: {} + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-constants@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-east-asian-width@1.5.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-tsconfig@4.13.7: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-from-package@0.0.0: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@17.4.0: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + helmet@8.1.0: {} + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + html-to-text@9.0.5: + dependencies: + "@selderee/plugin-htmlparser2": 0.11.0 + deepmerge: 4.3.1 + dom-serializer: 2.0.0 + htmlparser2: 8.0.2 + selderee: 0.11.0 + optional: true + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + optional: true + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + husky@9.1.7: {} + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ipaddr.js@1.9.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.5.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jiti@2.6.1: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + leac@0.6.0: + optional: true + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lint-staged@16.4.0: + dependencies: + commander: 14.0.3 + listr2: 9.0.5 + picomatch: 4.0.4 + string-argv: 0.3.2 + tinyexec: 1.0.4 + yaml: 2.8.3 + + listr2@9.0.5: + dependencies: + cli-truncate: 5.2.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + "@jridgewell/sourcemap-codec": 1.5.5 + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + memory-pager@1.5.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-function@5.0.1: {} + + mimic-response@3.1.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + + mongodb-connection-string-url@7.0.1: + dependencies: + "@types/whatwg-url": 13.0.0 + whatwg-url: 14.2.0 + + mongodb@7.1.1: + dependencies: + "@mongodb-js/saslprep": 1.4.6 + bson: 7.2.0 + mongodb-connection-string-url: 7.0.1 + + ms@2.0.0: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + nanoid@5.1.7: {} + + napi-build-utils@2.0.0: {} + + natural-compare@1.4.0: {} + + negotiator@1.0.0: {} + + node-abi@3.89.0: + dependencies: + semver: 7.7.4 + + node-addon-api@8.7.0: {} + + node-cron@4.2.1: {} + + node-gyp-build@4.8.4: {} + + node-releases@2.0.36: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parseley@0.12.1: + dependencies: + leac: 0.6.0 + peberminta: 0.9.0 + optional: true + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-to-regexp@8.4.1: {} + + peberminta@0.9.0: + optional: true + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postal-mime@2.7.4: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.89.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + + prelude-ls@1.2.1: {} + + prettier@3.8.1: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + qs@6.14.2: + dependencies: + side-channel: 1.1.0 + + random-bytes@1.0.0: {} + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + 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-promise-suspense@0.3.4: + dependencies: + fast-deep-equal: 2.0.1 + optional: true + + react-router-dom@7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-router: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + + react-router@7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + cookie: 1.1.1 + react: 19.2.4 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + + react@19.2.4: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + resend@6.10.0(@react-email/render@1.1.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): + dependencies: + postal-mime: 2.7.4 + svix: 1.88.0 + optionalDependencies: + "@react-email/render": 1.1.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + + resolve-pkg-maps@1.0.0: {} + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + rfdc@1.4.1: {} + + rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): + dependencies: + "@oxc-project/types": 0.122.0 + "@rolldown/pluginutils": 1.0.0-rc.12 + optionalDependencies: + "@rolldown/binding-android-arm64": 1.0.0-rc.12 + "@rolldown/binding-darwin-arm64": 1.0.0-rc.12 + "@rolldown/binding-darwin-x64": 1.0.0-rc.12 + "@rolldown/binding-freebsd-x64": 1.0.0-rc.12 + "@rolldown/binding-linux-arm-gnueabihf": 1.0.0-rc.12 + "@rolldown/binding-linux-arm64-gnu": 1.0.0-rc.12 + "@rolldown/binding-linux-arm64-musl": 1.0.0-rc.12 + "@rolldown/binding-linux-ppc64-gnu": 1.0.0-rc.12 + "@rolldown/binding-linux-s390x-gnu": 1.0.0-rc.12 + "@rolldown/binding-linux-x64-gnu": 1.0.0-rc.12 + "@rolldown/binding-linux-x64-musl": 1.0.0-rc.12 + "@rolldown/binding-openharmony-arm64": 1.0.0-rc.12 + "@rolldown/binding-wasm32-wasi": 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + "@rolldown/binding-win32-arm64-msvc": 1.0.0-rc.12 + "@rolldown/binding-win32-x64-msvc": 1.0.0-rc.12 + transitivePeerDependencies: + - "@emnapi/core" + - "@emnapi/runtime" + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.1 + transitivePeerDependencies: + - supports-color + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + selderee@0.11.0: + dependencies: + parseley: 0.12.1 + optional: true + + semver@6.3.1: {} + + semver@7.7.4: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-cookie-parser@2.7.2: {} + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@4.1.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + source-map-js@1.2.1: {} + + sparse-bitfield@3.0.3: + dependencies: + memory-pager: 1.5.0 + + standardwebhooks@1.0.0: + dependencies: + "@stablelib/base64": 1.0.1 + fast-sha256: 1.3.0 + + statuses@2.0.2: {} + + string-argv@0.3.2: {} + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-json-comments@2.0.1: {} + + svix@1.88.0: + dependencies: + standardwebhooks: 1.0.0 + uuid: 10.0.0 + + tailwindcss@4.2.2: {} + + tapable@2.3.2: {} + + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tinyexec@1.0.4: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + toidentifier@1.0.1: {} + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@6.0.2): + dependencies: + typescript: 6.0.2 + + tslib@2.8.1: + optional: true + + tsx@4.21.0: + dependencies: + esbuild: 0.27.4 + get-tsconfig: 4.13.7 + optionalDependencies: + fsevents: 2.3.3 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + turbo@2.9.1: + optionalDependencies: + "@turbo/darwin-64": 2.9.1 + "@turbo/darwin-arm64": 2.9.1 + "@turbo/linux-64": 2.9.1 + "@turbo/linux-arm64": 2.9.1 + "@turbo/windows-64": 2.9.1 + "@turbo/windows-arm64": 2.9.1 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript@6.0.2: {} + + uid-safe@2.1.5: + dependencies: + random-bytes: 1.0.0 + + ulid@3.0.2: {} + + undici-types@7.16.0: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + uuid@10.0.0: {} + + vary@1.1.2: {} + + vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.8 + rolldown: 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + tinyglobby: 0.2.15 + optionalDependencies: + "@types/node": 24.12.0 + esbuild: 0.27.4 + fsevents: 2.3.3 + jiti: 2.6.1 + tsx: 4.21.0 + yaml: 2.8.3 + transitivePeerDependencies: + - "@emnapi/core" + - "@emnapi/runtime" + + webidl-conversions@7.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + yallist@3.1.1: {} + + yaml@2.8.3: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@4.3.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5ca30d7 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - api + - web diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..9d3a302 --- /dev/null +++ b/turbo.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://turbo.build/schema.json", + "ui": "stream", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, + "lint": { + "outputs": [] + }, + "dev": { + "cache": false, + "persistent": true + }, + "seed": { + "cache": false + } + } +} diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..0f756ec --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env* + diff --git a/web/Caddyfile b/web/Caddyfile new file mode 100644 index 0000000..e91d4d1 --- /dev/null +++ b/web/Caddyfile @@ -0,0 +1,14 @@ +:80 { + coraza_waf { + load_owasp_crs + directives ` + Include /etc/caddy/coraza.conf + SecRuleEngine On + ` + } + + root * /srv + file_server + try_files {path} /index.html +} + diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..4e6ea9e --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,19 @@ +FROM node:22-slim AS build-app +WORKDIR /app +COPY package.json ./ +RUN corepack enable && pnpm install --frozen-lockfile=false +COPY . . +ARG VITE_API_URL +ENV VITE_API_URL=${VITE_API_URL} +RUN pnpm build + +FROM caddy:builder AS build-caddy +RUN xcaddy build \ + --with github.com/corazawaf/coraza-caddy/v2 + +FROM caddy:latest +COPY --from=build-caddy /usr/bin/caddy /usr/bin/caddy +COPY --from=build-app /app/dist /srv +COPY Caddyfile /etc/caddy/Caddyfile +COPY coraza.conf /etc/caddy/coraza.conf +EXPOSE 80 diff --git a/web/coraza.conf b/web/coraza.conf new file mode 100644 index 0000000..d1a2df6 --- /dev/null +++ b/web/coraza.conf @@ -0,0 +1,5 @@ +# Coraza configuration overrides +# Tune false positives here as they arise. + +SecAction "id:900000, phase:1, pass, t:none, nolog, setvar:tx.blocking_paranoia_level=1" + diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..346607c --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,42 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tsParser from "@typescript-eslint/parser"; +import tsPlugin from "@typescript-eslint/eslint-plugin"; + +export default [ + { + ignores: ["dist/**", "node_modules/**"] + }, + js.configs.recommended, + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module" + }, + globals: { + ...globals.browser + } + }, + plugins: { + "@typescript-eslint": tsPlugin, + "react-hooks": reactHooks, + "react-refresh": reactRefresh + }, + rules: { + ...tsPlugin.configs.recommended.rules, + ...reactHooks.configs.recommended.rules, + "no-undef": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_" + } + ] + } + } +]; diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..a4e5675 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Stoat Admin + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..1553709 --- /dev/null +++ b/web/package.json @@ -0,0 +1,44 @@ +{ + "name": "stoat-admin-web", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "AGPL-3.0-only", + "packageManager": "pnpm@10.6.3", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "vite", + "typecheck": "tsc -p tsconfig.json --noEmit", + "build": "tsc -p tsconfig.json && vite build", + "check": "pnpm lint && pnpm typecheck", + "start": "vite preview", + "preview": "pnpm start", + "lint": "eslint ." + }, + "dependencies": { + "@tanstack/react-query": "^5.62.11", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "ulid": "^3.0.2" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "^4.0.0", + "@types/node": "^24.12.0", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.1.0", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "tailwindcss": "^4.0.0", + "typescript": "^6.0.2", + "@typescript-eslint/eslint-plugin": "^8.18.2", + "@typescript-eslint/parser": "^8.18.2", + "vite": "^8.0.3" + } +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx new file mode 100644 index 0000000..1f664cf --- /dev/null +++ b/web/src/components/Layout.tsx @@ -0,0 +1,72 @@ +import { NavLink, Outlet } from "react-router-dom"; + +import { useAuth } from "../lib/auth"; + +const navItems = [ + { to: "/", label: "Dashboard", end: true }, + { to: "/invites", label: "Invites" }, + { to: "/users", label: "Users" } +]; + +export function Layout() { + const { logout, user } = useAuth(); + + return ( +
+
+ + +
+ +
+
+
+ ); +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..d29337a --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,44 @@ +@import "tailwindcss"; + +:root { + color-scheme: light; + --bg: #efe8db; + --bg-panel: rgba(255, 250, 240, 0.88); + --bg-panel-strong: rgba(31, 24, 17, 0.9); + --ink: #241d17; + --ink-muted: #675a4c; + --line: rgba(59, 43, 24, 0.12); + --accent: #b64926; + --accent-soft: rgba(182, 73, 38, 0.12); + --positive: #246d4f; + --warning: #8d6112; + --danger: #872f2f; + --shadow: 0 24px 80px rgba(53, 35, 16, 0.12); + font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif; +} + +body { + min-height: 100vh; + margin: 0; + background: + radial-gradient( + circle at top left, + rgba(182, 73, 38, 0.18), + transparent 34% + ), + radial-gradient( + circle at bottom right, + rgba(36, 109, 79, 0.12), + transparent 28% + ), + linear-gradient(180deg, #f7f1e7 0%, var(--bg) 100%); + color: var(--ink); +} + +#root { + min-height: 100vh; +} + +::selection { + background: rgba(182, 73, 38, 0.18); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..5bf0d18 --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,33 @@ +const API_BASE = import.meta.env.VITE_API_URL ?? ""; + +export class ApiError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +export async function apiFetch( + path: string, + options?: RequestInit +): Promise { + const response = await fetch(`${API_BASE}${path}`, { + ...options, + credentials: "include", + headers: { + "Content-Type": "application/json", + ...options?.headers + } + }); + + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as { + error?: string; + }; + throw new ApiError(response.status, body.error ?? "Request failed"); + } + + return (await response.json()) as T; +} diff --git a/web/src/lib/auth.tsx b/web/src/lib/auth.tsx new file mode 100644 index 0000000..3904b94 --- /dev/null +++ b/web/src/lib/auth.tsx @@ -0,0 +1,93 @@ +import { + createContext, + useContext, + useEffect, + useMemo, + useState, + type PropsWithChildren +} from "react"; + +import { apiFetch, ApiError } from "./api"; +import type { SessionUser } from "./types"; + +interface AuthContextValue { + user: SessionUser | null; + isLoading: boolean; + login: (username: string, password: string) => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: PropsWithChildren) { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + let isMounted = true; + + void apiFetch("/api/auth/me") + .then((sessionUser) => { + if (isMounted) { + setUser(sessionUser); + } + }) + .catch((error: unknown) => { + if (!isMounted) { + return; + } + + if (!(error instanceof ApiError) || error.status !== 401) { + console.error(error); + } + + setUser(null); + }) + .finally(() => { + if (isMounted) { + setIsLoading(false); + } + }); + + return () => { + isMounted = false; + }; + }, []); + + const value = useMemo( + () => ({ + user, + isLoading, + async login(username: string, password: string) { + const nextUser = await apiFetch("/api/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }) + }); + + setUser(nextUser); + }, + async logout() { + try { + await apiFetch("/api/auth/logout", { + method: "POST" + }); + } finally { + setUser(null); + } + } + }), + [isLoading, user] + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext); + + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + + return context; +} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..4673e53 --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,15 @@ +export function formatDateTime(value: string | null | undefined): string { + if (!value) { + return "Never"; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short" + }).format(date); +} diff --git a/web/src/lib/status.ts b/web/src/lib/status.ts new file mode 100644 index 0000000..8d5e34b --- /dev/null +++ b/web/src/lib/status.ts @@ -0,0 +1,48 @@ +const USER_FLAG_DELETED = 2; +const USER_FLAG_BANNED = 4; + +export function hasFlag(flags: number | undefined, mask: number): boolean { + return ((flags ?? 0) & mask) === mask; +} + +export function getUserStatus( + flags: number | undefined, + disabled?: boolean +): { + label: "active" | "banned" | "deleted"; + tone: string; +} { + if (hasFlag(flags, USER_FLAG_DELETED)) { + return { label: "deleted", tone: "text-red-800 bg-red-100 border-red-200" }; + } + + if (disabled || hasFlag(flags, USER_FLAG_BANNED)) { + return { + label: "banned", + tone: "text-amber-900 bg-amber-100 border-amber-200" + }; + } + + return { + label: "active", + tone: "text-emerald-900 bg-emerald-100 border-emerald-200" + }; +} + +export function getFlagLabels(flags: number | undefined): string[] { + const labels: string[] = []; + + if (hasFlag(flags, USER_FLAG_BANNED)) { + labels.push("banned"); + } + + if (hasFlag(flags, USER_FLAG_DELETED)) { + labels.push("deleted"); + } + + if (labels.length === 0) { + labels.push("none"); + } + + return labels; +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 0000000..f31ef7f --- /dev/null +++ b/web/src/lib/types.ts @@ -0,0 +1,75 @@ +export interface SessionUser { + username: string; +} + +export interface DashboardStats { + totalUsers: number; + bannedUsers: number; + pendingInvites: number; + recentBans: number; +} + +export type InviteRecordStatus = "pending" | "accepted" | "revoked" | "expired"; + +export interface InviteRecord { + id: number; + code: string; + email: string; + status: InviteRecordStatus; + created_at: string; + expires_at: string | null; + accepted_at: string | null; + resend_message_id: string | null; +} + +export interface InviteListResponse { + invites: InviteRecord[]; + count: number; +} + +export interface CreateInviteResponse { + invite: InviteRecord; + warning?: string; +} + +export interface AccountRecord { + _id: string; + email: string; + disabled: boolean; + verification?: { + status: "Verified" | "Pending" | "Moving"; + }; + deletion?: { + status: "Scheduled" | "WaitingForVerification" | "Deleted"; + after?: string; + }; +} + +export interface UserRecord { + _id: string; + username: string; + discriminator: string; + flags?: number; + avatar?: unknown; + account?: AccountRecord; +} + +export interface StrikeRecord { + _id: string; + user_id: string; + reason: string; + type?: "strike" | "suspension" | "ban"; +} + +export interface UsersResponse { + users: UserRecord[]; + total: number; + page: number; + limit: number; +} + +export interface UserDetailResponse { + user: UserRecord | null; + account: AccountRecord | null; + strikes: StrikeRecord[]; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..bf36d34 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,20 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { RouterProvider } from "react-router-dom"; + +import { AuthProvider } from "./lib/auth"; +import { router } from "./router"; +import "./index.css"; + +const queryClient = new QueryClient(); + +createRoot(document.getElementById("root")!).render( + + + + + + + +); diff --git a/web/src/router.tsx b/web/src/router.tsx new file mode 100644 index 0000000..8cea41d --- /dev/null +++ b/web/src/router.tsx @@ -0,0 +1,58 @@ +import { createBrowserRouter, Navigate } from "react-router-dom"; + +import { Layout } from "./components/Layout"; +import { useAuth } from "./lib/auth"; +import { DashboardView } from "./views/Dashboard"; +import { InvitesView } from "./views/Invites"; +import { LoginView } from "./views/Login"; +import { UserDetailView } from "./views/UserDetail"; +import { UsersView } from "./views/Users"; + +function ProtectedLayout() { + const { isLoading, user } = useAuth(); + + if (isLoading) { + return ( +
+
+ Loading admin session... +
+
+ ); + } + + if (!user) { + return ; + } + + return ; +} + +export const router = createBrowserRouter([ + { + path: "/login", + element: + }, + { + path: "/", + element: , + children: [ + { + index: true, + element: + }, + { + path: "invites", + element: + }, + { + path: "users", + element: + }, + { + path: "users/:id", + element: + } + ] + } +]); diff --git a/web/src/views/Dashboard.tsx b/web/src/views/Dashboard.tsx new file mode 100644 index 0000000..90b5829 --- /dev/null +++ b/web/src/views/Dashboard.tsx @@ -0,0 +1,85 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiFetch } from "../lib/api"; +import type { DashboardStats } from "../lib/types"; + +const statCards: Array<{ + key: keyof DashboardStats; + label: string; + note: string; +}> = [ + { + key: "totalUsers", + label: "Total Users", + note: "Accounts currently indexed in Stoat." + }, + { + key: "bannedUsers", + label: "Banned Users", + note: "Users with the banned flag or disabled account state." + }, + { + key: "pendingInvites", + label: "Pending Invites", + note: "Invites issued but not yet consumed." + }, + { + key: "recentBans", + label: "Recent Bans", + note: "Audit entries created in the last 30 days." + } +]; + +export function DashboardView() { + const statsQuery = useQuery({ + queryKey: ["dashboard-stats"], + queryFn: () => apiFetch("/api/dashboard/stats") + }); + const stats = statsQuery.data; + + return ( +
+
+

+ Overview +

+

+ Instance control room +

+

+ Fast access to invite state, moderation activity, and account volume + without depending on the public Stoat UI. +

+
+ + {statsQuery.isLoading ? ( +
+ Loading dashboard stats... +
+ ) : statsQuery.isError ? ( +
+ Failed to load dashboard stats. +
+ ) : stats ? ( +
+ {statCards.map((card) => ( +
+

+ {card.label} +

+

+ {stats[card.key]} +

+

+ {card.note} +

+
+ ))} +
+ ) : null} +
+ ); +} diff --git a/web/src/views/Invites.tsx b/web/src/views/Invites.tsx new file mode 100644 index 0000000..2473961 --- /dev/null +++ b/web/src/views/Invites.tsx @@ -0,0 +1,221 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; + +import { apiFetch, ApiError } from "../lib/api"; +import { formatDateTime } from "../lib/format"; +import type { + CreateInviteResponse, + InviteListResponse, + InviteRecord +} from "../lib/types"; + +const expiryOptions = [ + { label: "No expiry", value: "" }, + { label: "24 hours", value: "24" }, + { label: "48 hours", value: "48" }, + { label: "7 days", value: "168" }, + { label: "30 days", value: "720" } +]; + +const badgeStyles: Record = { + accepted: "border-emerald-200 bg-emerald-100 text-emerald-900", + pending: "border-amber-200 bg-amber-100 text-amber-900", + revoked: "border-red-200 bg-red-100 text-red-900", + expired: "border-stone-200 bg-stone-100 text-stone-700" +}; + +export function InvitesView() { + const queryClient = useQueryClient(); + const [email, setEmail] = useState(""); + const [expiresInHours, setExpiresInHours] = useState(""); + const [feedback, setFeedback] = useState(null); + const [error, setError] = useState(null); + + const invitesQuery = useQuery({ + queryKey: ["invites"], + queryFn: () => apiFetch("/api/invites") + }); + const inviteList = invitesQuery.data?.invites ?? []; + + const createInviteMutation = useMutation({ + mutationFn: () => + apiFetch("/api/invites", { + method: "POST", + body: JSON.stringify({ + email, + ...(expiresInHours ? { expiresInHours: Number(expiresInHours) } : {}) + }) + }), + onSuccess: (result) => { + setEmail(""); + setExpiresInHours(""); + setError(null); + setFeedback( + result.warning + ? `${result.warning}. Invite code: ${result.invite.code}` + : `Invite created for ${result.invite.email}. Code: ${result.invite.code}` + ); + void queryClient.invalidateQueries({ queryKey: ["invites"] }); + }, + onError: (mutationError) => { + setFeedback(null); + setError( + mutationError instanceof ApiError + ? mutationError.message + : "Failed to create invite" + ); + } + }); + + const revokeInviteMutation = useMutation({ + mutationFn: (code: string) => + apiFetch<{ success: true }>(`/api/invites/${code}`, { + method: "DELETE" + }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["invites"] }); + } + }); + + return ( +
+
+
+

+ Invites +

+

+ Issue and track access +

+
+

+ Pending invites stay valid in Stoat even if email delivery fails. +

+
+ +
+ + + + + + + {feedback ? ( +
+ {feedback} +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} +
+ +
+
+

Invite history

+
+ + {invitesQuery.isLoading ? ( +
+ Loading invites... +
+ ) : invitesQuery.isError ? ( +
+ Failed to load invites. +
+ ) : ( +
+ + + + + + + + + + + + + {inviteList.map((invite) => ( + + + + + + + + + ))} + +
EmailCodeStatusCreatedExpiresAction
{invite.email} + {invite.code} + + + {invite.status} + + + {formatDateTime(invite.created_at)} + + {formatDateTime(invite.expires_at)} + + {invite.status === "pending" ? ( + + ) : ( + + No action + + )} +
+
+ )} +
+
+ ); +} diff --git a/web/src/views/Login.tsx b/web/src/views/Login.tsx new file mode 100644 index 0000000..6063c49 --- /dev/null +++ b/web/src/views/Login.tsx @@ -0,0 +1,90 @@ +import { useState, type FormEvent } from "react"; +import { Navigate } from "react-router-dom"; + +import { ApiError } from "../lib/api"; +import { useAuth } from "../lib/auth"; + +export function LoginView() { + const { login, user, isLoading } = useAuth(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + if (!isLoading && user) { + return ; + } + + async function handleSubmit( + event: FormEvent + ): Promise { + event.preventDefault(); + setError(null); + setIsSubmitting(true); + + try { + await login(username, password); + } catch (submitError) { + if (submitError instanceof ApiError) { + setError(submitError.message); + } else { + setError("Unable to sign in"); + } + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+

+ Stoat Admin +

+

Sign in

+

+ This dashboard is intended for WireGuard-restricted admin access only. +

+ +
+ + + + + {error ? ( +
+ {error} +
+ ) : null} + + +
+
+
+ ); +} diff --git a/web/src/views/UserDetail.tsx b/web/src/views/UserDetail.tsx new file mode 100644 index 0000000..ff606b3 --- /dev/null +++ b/web/src/views/UserDetail.tsx @@ -0,0 +1,280 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { decodeTime } from "ulid"; +import { useState } from "react"; +import { useParams } from "react-router-dom"; + +import { apiFetch } from "../lib/api"; +import { formatDateTime } from "../lib/format"; +import { getFlagLabels, getUserStatus } from "../lib/status"; +import type { UserDetailResponse } from "../lib/types"; + +function strikeDate(ulidValue: string): string { + try { + return formatDateTime(new Date(decodeTime(ulidValue)).toISOString()); + } catch { + return ulidValue; + } +} + +export function UserDetailView() { + const { id } = useParams(); + const queryClient = useQueryClient(); + const [banReason, setBanReason] = useState(""); + const [deleteReason, setDeleteReason] = useState(""); + + const userQuery = useQuery({ + queryKey: ["user", id], + enabled: Boolean(id), + queryFn: () => apiFetch(`/api/users/${id}`) + }); + + const refresh = async (): Promise => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["user", id] }), + queryClient.invalidateQueries({ queryKey: ["users"] }), + queryClient.invalidateQueries({ queryKey: ["dashboard-stats"] }) + ]); + }; + + const banMutation = useMutation({ + mutationFn: () => + apiFetch<{ success: true }>(`/api/users/${id}/ban`, { + method: "POST", + body: JSON.stringify({ reason: banReason }) + }), + onSuccess: async () => { + setBanReason(""); + await refresh(); + } + }); + + const unbanMutation = useMutation({ + mutationFn: () => + apiFetch<{ success: true }>(`/api/users/${id}/unban`, { + method: "POST" + }), + onSuccess: refresh + }); + + const deleteMutation = useMutation({ + mutationFn: () => + apiFetch<{ success: true }>(`/api/users/${id}`, { + method: "DELETE", + body: JSON.stringify({ reason: deleteReason || undefined }) + }), + onSuccess: async () => { + setDeleteReason(""); + await refresh(); + } + }); + + if (!id) { + return ( +
+ Missing user id. +
+ ); + } + + if (userQuery.isLoading) { + return ( +
+ Loading user… +
+ ); + } + + const detail = userQuery.data; + + if (userQuery.isError || !detail?.user) { + return ( +
+ Failed to load user. +
+ ); + } + + const { user, account, strikes } = detail; + const status = getUserStatus(user.flags, account?.disabled); + const scheduledDeletion = account?.deletion?.status === "Scheduled"; + + return ( +
+
+

+ User Detail +

+

+ {user.username}#{user.discriminator} +

+
+ + {status.label} + + {getFlagLabels(user.flags).map((label) => ( + + {label} + + ))} +
+
+ +
+
+

Account info

+
+
+
+ Email +
+
+ {account?.email ?? "Unknown"} +
+
+
+
+ Verification +
+
+ {account?.verification?.status ?? "Unknown"} +
+
+
+
+ User ID +
+
{user._id}
+
+
+
+ Deletion state +
+
+ {account?.deletion?.status ?? "Not scheduled"} + {account?.deletion?.after + ? ` · ${formatDateTime(account.deletion.after)}` + : ""} +
+
+
+
+ +
+

Actions

+ + {scheduledDeletion ? ( +

+ Deletion is already scheduled. Stoat's `crond` daemon will + handle the remaining cleanup. +

+ ) : status.label === "banned" ? ( +
+

+ The user is currently banned. You can clear the disabled state + and banned flag. +

+ +
+ ) : ( +
+
+

Ban user

+