diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 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..701ecbb --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# 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 the HTTPS browser origin allowed by CORS. +ADMIN_API_PORT=5181 +ADMIN_WEB_ORIGIN=https://localhost:9443 + +# Admin hostname terminated by the dedicated Caddy proxy. Use localhost for +# local compose usage and replace it with a real name for deployment. +ADMIN_HOSTNAME=localhost + +# Compose-level convenience variables for the dedicated admin proxy. These +# defaults avoid privileged ports locally; set 80/443 in deployment if needed. +ADMIN_BIND_IP=127.0.0.1 +ADMIN_HTTP_PORT=9080 +ADMIN_HTTPS_PORT=9443 +# Optional frontend API base URL override for standalone web builds. +ADMIN_WEB_API_URL= 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..b1a1218 --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +# 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 now ships with a dedicated `admin-proxy` Caddy service that terminates HTTPS for the admin stack with Caddy's internal CA. + +## 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 +├── proxy/ # Caddy + Coraza reverse proxy image +├── web/ # Vite + React frontend and static image build +├── 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 +``` + +## Admin Setup + +To create the admin user, run this command inside the admin container + +`node dist/seed.js --username admin --password ` + +## 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 +- A hostname for the admin dashboard that resolves on your WireGuard/private network +- A way to trust Caddy's internal root CA on the admin devices that will access the dashboard + +## Quick Start + +1. Enable `pnpm`: + +```sh +corepack enable +``` + +2. Install dependencies: + +```sh +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 +pnpm seed -- --username admin --password '' +``` + +5. Run both packages together through Turborepo: + +```sh +pnpm dev +``` + +Useful targeted variants: + +```sh +pnpm dev:api +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 HTTPS browser origin allowed by CORS | +| `ADMIN_HOSTNAME` | Hostname served by the dedicated Caddy proxy | +| `ADMIN_BIND_IP` | Compose bind IP for the proxy's published ports | +| `ADMIN_HTTP_PORT` | Published HTTP port for redirect handling | +| `ADMIN_HTTPS_PORT` | Published HTTPS port for the admin dashboard | +| `ADMIN_WEB_API_URL` | Optional frontend API override outside compose | + +## 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 local compose use, the proxy defaults to `https://localhost:9443` and `http://localhost:9080`. For deployed hosts, set `ADMIN_HOSTNAME` to the real admin name and switch `ADMIN_HTTP_PORT`/`ADMIN_HTTPS_PORT` to `80`/`443` or use [compose.override.example.yml](/home/jasonross/workspace/stoat-admin/compose.override.example.yml) as a starting point. + +The deployment topology is: + +- `admin-proxy` is built from [proxy/Dockerfile](/home/jasonross/workspace/stoat-admin/proxy/Dockerfile), publishes the configured HTTP and HTTPS ports, issues a private certificate from Caddy's internal CA, applies Coraza, and reverse-proxies `/api/*` to `admin-api` and everything else to `admin-web`. +- `admin-web` and `admin-api` are no longer published directly on the host. +- `admin-web` serves the built Vite bundle privately on the admin network. +- `admin-api` stays attached to the shared Stoat network for MongoDB access and also joins a private admin network used by the proxy. + +Before starting the stack, point `ADMIN_HOSTNAME` at the host running `admin-proxy` on your WireGuard/private network and set `ADMIN_WEB_ORIGIN` to `https://`. + +After the proxy has started once, install Caddy's root CA on each admin device before browsing to the dashboard. One way to export it is: + +```sh +docker compose exec admin-proxy sh -c 'cat /data/caddy/pki/authorities/local/root.crt' > admin-proxy-root.crt +``` + +Then import `admin-proxy-root.crt` into the OS/browser trust store for the devices that should access the dashboard. + +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. +- In the composed deployment, the frontend always uses same-origin `/api` requests through `admin-proxy`; `VITE_API_URL` is only useful outside compose. +- 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..6fe0218 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,31 @@ +# Security Policy + +## Security Model + +Stoat Admin is designed to keep the application containers private even when the admin entrypoint is fronted by its own HTTPS proxy. The intended deployment model is: + +- `admin-proxy` is the only published service and terminates HTTPS for the admin stack with Caddy's internal CA +- `admin-web` and `admin-api` are reachable only on the private admin container network +- `admin-api` joins the Stoat network only so it can reach MongoDB +- 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 the reverse proxy limits what is exposed, the shared Stoat network is used only where needed, and the application session still limits user access. + +## 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 expose only `admin-proxy`, not `admin-web` or `admin-api`. +- Trust Caddy's internal root CA only on the admin devices that should access the dashboard. +- Protect the `admin_proxy_data` volume. It contains the private CA material used to issue the dashboard certificate. diff --git a/admin-stack/README.md b/admin-stack/README.md new file mode 100644 index 0000000..129dd5e --- /dev/null +++ b/admin-stack/README.md @@ -0,0 +1,69 @@ +# Admin Stack + +This repository contains the deployment configuration for the Admin interface. + +## Prerequisites + +1. Same host as Stoat, rootless user with linger. +2. Ansible + podman + WireGuard userspace tools installed. +3. GCP credentials for Secret Manager. +4. Public DNS record for `admin.${DOMAIN}` in Google Cloud DNS pointing to the WG server IP (or no record at all if using `tls internal`). +5. Cloud DNS service account provisioned with `roles/dns.admin` and stored in Secret Manager. + +## First Deploy + +Run the bootstrap script: + +```bash +./scripts/bootstrap.sh +``` + +## Adding a new WG client + +1. Edit `wg_clients` in `ansible/inventory.yml` (or your overriding group_vars). +2. Re-run the wireguard playbook: + ```bash + ansible-playbook -i ansible/inventory.yml ansible/wireguard.yml + ``` +3. Distribute the new client config from `./generated/clients/.conf`. + +## Removing a WG client + +1. Remove the client from `wg_clients`. +2. Re-run the playbook. +3. Verify in `wg show wg0` that the peer is gone. + +## Rotating the WG server key + +Rotating the server key is disruptive — every client config must be regenerated and redistributed. + +1. Remove the old key from Secret Manager or create a new version. +2. Re-run the wireguard playbook. + +## Rotating the Caddy DNS service account key + +1. Generate a new key with `gcloud iam service-accounts keys create`. +2. Push to Secret Manager as a new version. +3. Re-run bootstrap step 6 to materialize the key. +4. Restart Caddy (`podman compose restart caddy`). +5. Disable the old key with `gcloud iam service-accounts keys disable` and finally delete after a grace period. + +## Redeploy Procedure + +1. `podman compose pull` +2. `podman compose up -d` +3. `./scripts/verify.sh` + +## Secret Rotation Procedure + +1. Update the secret in GCP Secret Manager (e.g. `admin-env`). +2. Materialize the `.env` file again. +3. `podman compose up -d` to recreate containers with the new environment. + +## SQLite Backup and Recovery Procedure + +Backups are handled by `scripts/sqlite-backup.sh`. + +1. To restore, stop the `admin-api` container. +2. Replace the live `admin.db` in the `admin-sqlite` named volume with the snapshot file. +3. Restart the `admin-api` container. diff --git a/admin-stack/ansible/inventory.yml b/admin-stack/ansible/inventory.yml new file mode 100644 index 0000000..85f6d23 --- /dev/null +++ b/admin-stack/ansible/inventory.yml @@ -0,0 +1,17 @@ +all: + children: + admin_host: + hosts: + localhost: + ansible_connection: local + vars: + host_public_ip: "192.0.2.1" + wg_subnet: "10.42.0.0/24" + wg_server_ip: "10.42.0.1" + wg_listen_port: 51820 + wg_clients: + - name: jason-laptop + ip: "10.42.0.10" + - name: jason-phone + ip: "10.42.0.11" + podman_user: "stoat" diff --git a/admin-stack/ansible/networks.yml b/admin-stack/ansible/networks.yml new file mode 100644 index 0000000..ce2cada --- /dev/null +++ b/admin-stack/ansible/networks.yml @@ -0,0 +1,25 @@ +--- +- name: Podman Networks Setup + hosts: admin_host + become: true + become_user: "{{ podman_user }}" + tasks: + - name: Verify linger is enabled + ansible.builtin.command: loginctl show-user {{ podman_user }} + register: linger_check + changed_when: false + failed_when: "'Linger=yes' not in linger_check.stdout" + + - name: Admin edge network + containers.podman.podman_network: + name: admin-edge + driver: bridge + subnet: 10.89.20.0/24 + internal: false + state: present + + - name: Assert stoat-shared exists (Stoat's Ansible owns it) + ansible.builtin.command: podman network inspect stoat-shared + register: shared_check + changed_when: false + failed_when: shared_check.rc != 0 diff --git a/admin-stack/ansible/templates/client.conf.j2 b/admin-stack/ansible/templates/client.conf.j2 new file mode 100644 index 0000000..6efb673 --- /dev/null +++ b/admin-stack/ansible/templates/client.conf.j2 @@ -0,0 +1,11 @@ +[Interface] +PrivateKey = {{ client.private_key }} +Address = {{ client.ip }}/24 +DNS = 1.1.1.1 + +[Peer] +PublicKey = {{ wg_server_public_key }} +PresharedKey = {{ client.psk }} +Endpoint = {{ host_public_ip }}:{{ wg_listen_port }} +AllowedIPs = {{ wg_subnet }} +PersistentKeepalive = 25 diff --git a/admin-stack/ansible/templates/wg0.conf.j2 b/admin-stack/ansible/templates/wg0.conf.j2 new file mode 100644 index 0000000..92d5985 --- /dev/null +++ b/admin-stack/ansible/templates/wg0.conf.j2 @@ -0,0 +1,13 @@ +[Interface] +PrivateKey = {{ wg_server_private_key }} +Address = {{ wg_server_ip }}/24 +ListenPort = {{ wg_listen_port }} +SaveConfig = false + +{% for client in wg_clients_enriched %} +[Peer] +# {{ client.name }} +PublicKey = {{ client.public_key }} +PresharedKey = {{ client.psk }} +AllowedIPs = {{ client.ip }}/32 +{% endfor %} diff --git a/admin-stack/ansible/wireguard.yml b/admin-stack/ansible/wireguard.yml new file mode 100644 index 0000000..644a3c8 --- /dev/null +++ b/admin-stack/ansible/wireguard.yml @@ -0,0 +1,153 @@ +--- +- name: WireGuard Host Setup + hosts: admin_host + become: true + tasks: + - name: Ensure wireguard and tools are installed + ansible.builtin.package: + name: + - wireguard-tools + - firewalld + state: present + + - name: Ensure firewalld is running and enabled + ansible.builtin.systemd: + name: firewalld + state: started + enabled: true + + - name: Open WG UDP port on public zone + ansible.posix.firewalld: + zone: public + port: "{{ wg_listen_port }}/udp" + permanent: true + state: enabled + notify: Reload firewalld + + - name: Check if WG server private key exists in Secret Manager + delegate_to: localhost + become: false + ansible.builtin.command: > + gcloud secrets versions access latest --secret=admin-wg-server-key --project={{ lookup('env', 'GCP_PROJECT_ID') }} + register: wg_sm_check + failed_when: false + changed_when: false + + - name: Generate WG server private key locally if not in Secret Manager + delegate_to: localhost + become: false + ansible.builtin.command: wg genkey + register: wg_local_gen + when: wg_sm_check.rc != 0 + changed_when: true + + - name: Create Secret in Secret Manager if missing + delegate_to: localhost + become: false + ansible.builtin.command: > + gcloud secrets create admin-wg-server-key --project={{ lookup('env', 'GCP_PROJECT_ID') }} --replication-policy="automatic" + when: wg_sm_check.rc != 0 + failed_when: false + changed_when: false + + - name: Push new WG server private key to Secret Manager + delegate_to: localhost + become: false + ansible.builtin.command: > + gcloud secrets versions add admin-wg-server-key + --data-file=- + --project={{ lookup('env', 'GCP_PROJECT_ID') }} + args: + stdin: "{{ wg_local_gen.stdout }}" + when: wg_sm_check.rc != 0 + + - name: Set server private key variable + ansible.builtin.set_fact: + wg_server_private_key: "{{ wg_sm_check.stdout if wg_sm_check.rc == 0 else wg_local_gen.stdout }}" + no_log: true + + - name: Generate server public key + delegate_to: localhost + become: false + ansible.builtin.command: wg pubkey + args: + stdin: "{{ wg_server_private_key }}" + register: wg_server_pub_gen + changed_when: false + + - name: Set server public key variable + ansible.builtin.set_fact: + wg_server_public_key: "{{ wg_server_pub_gen.stdout }}" + + - name: Ensure /etc/wireguard directory exists + ansible.builtin.file: + path: /etc/wireguard + state: directory + mode: "0700" + + - name: Generate client keys + delegate_to: localhost + become: false + ansible.builtin.shell: | + priv=$(wg genkey) + pub=$(echo "$priv" | wg pubkey) + psk=$(wg genpsk) + echo '{"private_key": "'$priv'", "public_key": "'$pub'", "psk": "'$psk'"}' + register: wg_client_keys_gen + with_items: "{{ wg_clients }}" + changed_when: true + no_log: true + + - name: Enrich wg_clients with keys + ansible.builtin.set_fact: + wg_clients_enriched: >- + {{ + wg_clients_enriched | default([]) + + [item.0 | combine(item.1.stdout | from_json)] + }} + loop: "{{ wg_clients | zip(wg_client_keys_gen.results) | list }}" + no_log: true + + - name: Render server wg0.conf + ansible.builtin.template: + src: templates/wg0.conf.j2 + dest: /etc/wireguard/wg0.conf + mode: "0600" + notify: Restart wg-quick + + - name: Enable and start wg-quick@wg0 + ansible.builtin.systemd: + name: wg-quick@wg0 + state: started + enabled: true + + - name: Ensure client config directory exists on control machine + delegate_to: localhost + become: false + ansible.builtin.file: + path: "{{ playbook_dir }}/../generated/clients" + state: directory + mode: "0700" + + - name: Render client configs on control machine + delegate_to: localhost + become: false + ansible.builtin.template: + src: templates/client.conf.j2 + dest: "{{ playbook_dir }}/../generated/clients/{{ item.name }}.conf" + mode: "0600" + loop: "{{ wg_clients_enriched }}" + vars: + client: "{{ item }}" + no_log: true + + handlers: + - name: Reload firewalld + ansible.builtin.systemd: + name: firewalld + state: reloaded + + - name: Restart wg-quick + ansible.builtin.systemd: + name: wg-quick@wg0 + state: restarted diff --git a/admin-stack/caddy/Caddyfile b/admin-stack/caddy/Caddyfile new file mode 100644 index 0000000..89eaaed --- /dev/null +++ b/admin-stack/caddy/Caddyfile @@ -0,0 +1,20 @@ +{ + email {$ACME_EMAIL} +} + +admin.{$DOMAIN} { + tls { + dns googleclouddns { + gcp_project {$GCP_PROJECT_ID} + gcp_application_default /etc/caddy/credentials/sa.json + } + } + + handle /api/* { + reverse_proxy admin-api:3000 + } + + handle { + reverse_proxy admin-frontend:3000 + } +} diff --git a/admin-stack/caddy/Dockerfile b/admin-stack/caddy/Dockerfile new file mode 100644 index 0000000..f52c6ed --- /dev/null +++ b/admin-stack/caddy/Dockerfile @@ -0,0 +1,6 @@ +FROM caddy:2.8.4-builder AS builder +RUN xcaddy build \ + --with github.com/caddy-dns/googleclouddns + +FROM caddy:2.8.4 +COPY --from=builder /usr/bin/caddy /usr/bin/caddy diff --git a/admin-stack/compose.yaml b/admin-stack/compose.yaml new file mode 100644 index 0000000..2722af7 --- /dev/null +++ b/admin-stack/compose.yaml @@ -0,0 +1,59 @@ +networks: + admin-edge: + external: true + stoat-shared: + external: true + +volumes: + admin-sqlite: + caddy-data: + caddy-config: + +services: + caddy: + build: ./caddy + ports: + - "${WG_SERVER_IP}:80:80" + - "${WG_SERVER_IP}:443:443" + volumes: + - caddy-data:/data + - caddy-config:/config + - ./secrets/caddy-dns-sa.json:/etc/caddy/credentials/sa.json:ro + environment: + GCP_PROJECT_ID: ${GCP_PROJECT_ID} + ACME_EMAIL: ${ACME_EMAIL} + DOMAIN: ${DOMAIN} + networks: + - admin-edge + restart: unless-stopped + + admin-frontend: + image: ${ADMIN_FRONTEND_IMAGE} + environment: + - API_URL=http://admin-api:3000 + networks: + - admin-edge + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/"] + interval: 30s + timeout: 10s + retries: 3 + + admin-api: + image: ${ADMIN_API_IMAGE} + volumes: + - admin-sqlite:/data/db + environment: + - SQLITE_DB_PATH=/data/db/admin.db + - MONGO_URL=mongodb://admin_stack_ro:${ADMIN_STACK_DB_PASSWORD}@mongodb:27017/revolt + - SESSION_SECRET=${SESSION_SECRET} + networks: + - admin-edge + - stoat-shared + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 diff --git a/admin-stack/scripts/bootstrap.sh b/admin-stack/scripts/bootstrap.sh new file mode 100755 index 0000000..82d9b50 --- /dev/null +++ b/admin-stack/scripts/bootstrap.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "=== 1. Verifying rootless podman ===" +PODMAN_USER=$(whoami) +if ! loginctl show-user ${PODMAN_USER} | grep -q "Linger=yes"; then + echo "Error: Linger is not enabled for user ${PODMAN_USER}" + exit 1 +fi + +echo "=== 2. Ansible: WireGuard ===" +ansible-playbook -i ansible/inventory.yml ansible/wireguard.yml + +echo "Client configs generated at: $(realpath ./ansible/../generated/clients)" +echo "Please securely copy these to your client devices." + +echo "=== 3. Verify wg0 ===" +if ! wg show wg0 >/dev/null 2>&1; then + echo "Error: wg0 interface is not up" + exit 1 +fi + +echo "=== 4. Ansible: Networks ===" +ansible-playbook -i ansible/inventory.yml ansible/networks.yml + +echo "=== 5. Materialize .env ===" +gcloud secrets versions access latest --secret=admin-env > .env +chmod 600 .env + +echo "=== 6. Materialize Caddy SA Key ===" +mkdir -p ./secrets +gcloud secrets versions access latest --secret=admin-caddy-dns-sa-key > ./secrets/caddy-dns-sa.json +chmod 600 ./secrets/caddy-dns-sa.json + +echo "=== 7. Podman Compose Build ===" +podman compose build + +echo "=== 8. Podman Compose Pull ===" +podman compose pull + +echo "=== 9. Podman Compose Up ===" +podman compose up -d + +echo "=== 10. Wait for services ===" +echo "Waiting up to 120s for services to become healthy..." +sleep 10 # Let them start + +echo "=== 11. Verify ===" +./scripts/verify.sh diff --git a/admin-stack/scripts/sqlite-backup.sh b/admin-stack/scripts/sqlite-backup.sh new file mode 100755 index 0000000..6ed5548 --- /dev/null +++ b/admin-stack/scripts/sqlite-backup.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +SRC_VOLUME="admin-sqlite" +DEST_DIR="/var/backups/admin-sqlite" +TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)" +DEST_FILE="${DEST_DIR}/admin-${TIMESTAMP}.sqlite" + +mkdir -p "${DEST_DIR}" + +# Use sqlite3 .backup for an atomic snapshot +podman run --rm \ + -v "${SRC_VOLUME}:/data:ro" \ + -v "${DEST_DIR}:/out" \ + docker.io/keinos/sqlite3:3.42.0 \ + sqlite3 /data/admin.db ".backup '/out/admin-${TIMESTAMP}.sqlite'" + +# Retain last 7 snapshots locally +ls -1t "${DEST_DIR}"/admin-*.sqlite | tail -n +8 | xargs -r rm diff --git a/admin-stack/scripts/verify.sh b/admin-stack/scripts/verify.sh new file mode 100755 index 0000000..5a4920f --- /dev/null +++ b/admin-stack/scripts/verify.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +WG_SERVER_IP="${WG_SERVER_IP:-10.42.0.1}" +HOST_PUBLIC_IP=$(curl -s ifconfig.me || echo "127.0.0.1") + +echo "Running verification checks..." + +# 1. WG interface up +if wg show wg0 >/dev/null 2>&1 && wg show wg0 peers | grep -q .; then + echo "[ok] WG interface wg0 is up and has peers" +else + echo "[fail] WG interface wg0 is down or has no peers" + exit 1 +fi + +# 2. WG IP bound +if ip -o addr show wg0 | grep -q "${WG_SERVER_IP}"; then + echo "[ok] WG interface wg0 bound to ${WG_SERVER_IP}" +else + echo "[fail] WG interface wg0 is not bound to ${WG_SERVER_IP}" + exit 1 +fi + +# 3. Caddy listening on WG IP, NOT public IP +if ss -tlnp | grep -E ':443\b' | grep -q "${WG_SERVER_IP}"; then + if ss -tlnp | grep -E ':443\b' | grep -q -E "0\.0\.0\.0|::|\*"; then + echo "[fail] Caddy is bound to public IP" + exit 1 + else + echo "[ok] Caddy is bound only to WG IP" + fi +else + echo "[fail] Caddy is not bound to ${WG_SERVER_IP}:443" + exit 1 +fi + +# 4. Admin endpoint NOT reachable from public +if curl --max-time 3 -k https://${HOST_PUBLIC_IP}/ >/dev/null 2>&1; then + echo "[fail] Admin endpoint is reachable from public IP" + exit 1 +else + echo "[ok] Admin endpoint is not reachable from public IP" +fi + +# 5. Networks present +if podman network inspect admin-edge >/dev/null 2>&1 && podman network inspect stoat-shared >/dev/null 2>&1; then + echo "[ok] Podman networks admin-edge and stoat-shared exist" +else + echo "[fail] Required podman networks are missing" + exit 1 +fi + +# 6. All expected services healthy +SERVICES=("admin-stack-caddy-1" "admin-stack-admin-frontend-1" "admin-stack-admin-api-1") +for service in "${SERVICES[@]}"; do + if podman ps --format "{{.Names}}" | grep -q "${service}"; then + echo "[ok] Service ${service} is running" + else + echo "[fail] Service ${service} is not running" + exit 1 + fi +done + +# 7. admin-api can reach MongoDB +API_CONTAINER=$(podman ps -q -f name=admin-stack-admin-api-1) +if podman exec "${API_CONTAINER}" curl -s http://localhost:3000/health >/dev/null 2>&1; then + echo "[ok] admin-api healthcheck passed" +else + echo "[fail] admin-api healthcheck failed" + exit 1 +fi + +# 8. No unexpected host ports bound +if podman ps --format '{{.Ports}}' | grep -v "${WG_SERVER_IP}" | grep -q ":"; then + echo "[fail] Unexpected ports bound" + podman ps --format '{{.Names}}: {{.Ports}}' + exit 1 +else + echo "[ok] No unexpected host ports bound" +fi + +echo "All checks passed!" diff --git a/api/.dockerignore b/api/.dockerignore new file mode 100644 index 0000000..1a12205 --- /dev/null +++ b/api/.dockerignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +.turbo/ +.env +.env.* +secrets.env +*.db +*.db-* +*.sqlite +*.sqlite* +data/ +coverage/ +.DS_Store diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..36b8026 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,16 @@ +FROM node:24-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:24-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..a25cd0e --- /dev/null +++ b/api/package.json @@ -0,0 +1,58 @@ +{ + "name": "stoat-admin-api", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "AGPL-3.0-only", + "packageManager": "pnpm@10.6.3", + "pnpm": { + "onlyBuiltDependencies": [ + "argon2", + "better-sqlite3", + "esbuild" + ] + }, + "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..daa90cb --- /dev/null +++ b/api/src/db/sqlite.ts @@ -0,0 +1,114 @@ +import { existsSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import Database from "better-sqlite3"; + +import type { AdminUserRecord, InviteRecord } from "./types.js"; + +// Keep local development aligned with the compose-mounted ./data directory. +const workspaceSqlitePath = resolve( + dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "..", + "data", + "admin.db" +); + +function resolveSqlitePath(): string { + if (existsSync("/data")) { + return "/data/admin.db"; + } + + return workspaceSqlitePath; +} + +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..462a180 --- /dev/null +++ b/api/src/index.ts @@ -0,0 +1,66 @@ +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.set("trust proxy", 1); + + 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..160723f --- /dev/null +++ b/api/src/middleware/auth.ts @@ -0,0 +1,44 @@ +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"; +const SESSION_COOKIE_SECURE = + new URL(env.ADMIN_WEB_ORIGIN).protocol === "https:"; +export const SESSION_COOKIE_OPTIONS = { + path: "/", + httpOnly: true, + sameSite: "strict", + secure: SESSION_COOKIE_SECURE +} as const; + +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: { + ...SESSION_COOKIE_OPTIONS, + maxAge: 2 * 60 * 60 * 1000 + } +}); + +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..e7ca08d --- /dev/null +++ b/api/src/routes/auth.ts @@ -0,0 +1,74 @@ +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, + SESSION_COOKIE_OPTIONS +} 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, SESSION_COOKIE_OPTIONS); + 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..82dc80e --- /dev/null +++ b/api/src/seed.ts @@ -0,0 +1,100 @@ +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 rawArgs = process.argv.slice(2); + const normalizedArgs = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs; + + const parsed = parseArgs({ + args: normalizedArgs, + 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..898a754 --- /dev/null +++ b/compose.override.example.yml @@ -0,0 +1,21 @@ +services: + admin-proxy: + environment: + ADMIN_HOSTNAME: admin.example.com + ADMIN_HTTP_PORT: 80 + ADMIN_HTTPS_PORT: 443 + ports: + - "10.0.0.1:80:80" + - "10.0.0.1:443:443" + + admin-api: + environment: + ADMIN_API_PORT: 5181 + ADMIN_WEB_ORIGIN: https://admin.example.com + +# Common customizations: +# - Change the external network name in compose.yml if your Stoat stack uses a different name. +# - Bind admin-proxy to the interface IP that should answer ports 80/443. +# - Use ADMIN_HTTP_PORT/ADMIN_HTTPS_PORT=9080/9443 for local compose HTTPS without privileged ports. +# - Ensure ADMIN_HOSTNAME resolves to that IP on your WireGuard/private network. +# - Add resource limits or alternate image tags per deployment. diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..dd02a31 --- /dev/null +++ b/compose.yml @@ -0,0 +1,56 @@ +volumes: + admin_proxy_data: + admin_proxy_config: + +networks: + stoat: + external: true + name: stoat_default + admin: + +services: + admin-api: + build: + context: ./api + image: ghcr.io/owner/stoat-admin-api:latest + restart: unless-stopped + expose: + - "${ADMIN_API_PORT:-5181}" + volumes: + - ./data:/data + - ./.env:/app/.env:ro + networks: + - admin + - stoat + + admin-web: + build: + context: ./web + image: ghcr.io/owner/stoat-admin-web:latest + restart: unless-stopped + expose: + - "80" + networks: + - admin + + admin-proxy: + build: + context: ./proxy + image: ghcr.io/owner/stoat-admin-proxy:latest + restart: unless-stopped + depends_on: + - admin-api + - admin-web + environment: + ADMIN_API_PORT: ${ADMIN_API_PORT:-5181} + ADMIN_HOSTNAME: ${ADMIN_HOSTNAME:-localhost} + ADMIN_HTTP_PORT: ${ADMIN_HTTP_PORT:-9080} + ADMIN_HTTPS_PORT: ${ADMIN_HTTPS_PORT:-9443} + ports: + - "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_HTTP_PORT:-9080}:${ADMIN_HTTP_PORT:-9080}" + - "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_HTTPS_PORT:-9443}:${ADMIN_HTTPS_PORT:-9443}" + volumes: + - admin_proxy_data:/data + - admin_proxy_config:/config + networks: + - admin 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/lint-staged.config.js b/lint-staged.config.js new file mode 100644 index 0000000..b966af9 --- /dev/null +++ b/lint-staged.config.js @@ -0,0 +1,51 @@ +import path from "node:path"; + +const repoRoot = process.cwd(); + +const quote = (value) => JSON.stringify(value); + +const inWorkspace = (workspace, file) => { + const relativePath = path.relative(repoRoot, file); + return ( + relativePath === workspace || + relativePath.startsWith(`${workspace}${path.sep}`) + ); +}; + +const commandForWorkspace = (workspaceName, workspaceDir, files) => { + const workspaceFiles = files.filter( + (file) => inWorkspace(workspaceDir, file) && /\.(ts|tsx)$/.test(file) + ); + + if (workspaceFiles.length === 0) { + return null; + } + + return `pnpm --filter ${workspaceName} exec eslint --fix ${workspaceFiles + .map(quote) + .join(" ")}`; +}; + +export default { + "**/*": (files) => { + const commands = []; + + if (files.length > 0) { + commands.push( + `prettier --write --ignore-unknown ${files.map(quote).join(" ")}` + ); + } + + const apiCommand = commandForWorkspace("stoat-admin-api", "api", files); + if (apiCommand) { + commands.push(apiCommand); + } + + const webCommand = commandForWorkspace("stoat-admin-web", "web", files); + if (webCommand) { + commands.push(webCommand); + } + + return commands; + } +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..a022070 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "stoat-admin", + "private": true, + "type": "module", + "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": { + "dev": "turbo run dev --parallel", + "build": "turbo run build", + "lint": "turbo run lint", + "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" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..5d60302 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3977 @@ +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/proxy/Caddyfile b/proxy/Caddyfile new file mode 100644 index 0000000..fcb12a1 --- /dev/null +++ b/proxy/Caddyfile @@ -0,0 +1,28 @@ +{ + http_port {$ADMIN_HTTP_PORT:9080} + https_port {$ADMIN_HTTPS_PORT:9443} + order coraza_waf first +} + +{$ADMIN_HOSTNAME:localhost} { + tls internal + + encode zstd gzip + + coraza_waf { + load_owasp_crs + directives ` + Include /etc/caddy/coraza.conf + SecRuleEngine On + ` + } + + @api path /api/* + handle @api { + reverse_proxy admin-api:{$ADMIN_API_PORT:5181} + } + + handle { + reverse_proxy admin-web:80 admin-web:443 admin-web:9080 admin-web:9443 + } +} diff --git a/proxy/Dockerfile b/proxy/Dockerfile new file mode 100644 index 0000000..ba6573d --- /dev/null +++ b/proxy/Dockerfile @@ -0,0 +1,9 @@ +FROM caddy:2-builder-alpine AS build-caddy +RUN xcaddy build \ + --with github.com/corazawaf/coraza-caddy/v2 + +FROM caddy:2-alpine +COPY --from=build-caddy /usr/bin/caddy /usr/bin/caddy +COPY Caddyfile /etc/caddy/Caddyfile +COPY coraza.conf /etc/caddy/coraza.conf +EXPOSE 80 443 9080 9443 \ No newline at end of file diff --git a/proxy/coraza.conf b/proxy/coraza.conf new file mode 100644 index 0000000..3848bc5 --- /dev/null +++ b/proxy/coraza.conf @@ -0,0 +1,4 @@ +# 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" \ No newline at end of file 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..a7d0b4f --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.turbo/ +.env* +.env + diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..d026b6d --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,8 @@ +FROM node:24-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} +EXPOSE 9080 9443 \ No newline at end of file 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..94ccf12 --- /dev/null +++ b/web/package.json @@ -0,0 +1,49 @@ +{ + "name": "stoat-admin-web", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "AGPL-3.0-only", + "packageManager": "pnpm@10.6.3", + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + }, + "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

+