commit ce8f719a05c392948bde9f959ac09795574672ae Author: SowinskiBraeden Date: Sun Jul 5 16:54:59 2026 -0700 initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..54dc77c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +**/node_modules +**/dist +.env +.env.* +!.env.example +.git +coverage +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..16c15d8 --- /dev/null +++ b/.env.example @@ -0,0 +1,52 @@ +# --- Core --- +# Postgres connection string. Matches docker-compose.yml defaults. +DATABASE_URL=postgresql://reforger:reforger@127.0.0.1:5433/reforger_panel +# Random string, at least 32 characters. Used to sign OAuth state. +SESSION_SECRET=change-me-to-a-long-random-string-1234 +# API listen port and the origin the web app is served from (CORS + OAuth redirects). +PORT=3001 +WEB_ORIGIN=http://localhost:5173 +NODE_ENV=development + +# --- Discord OAuth --- +# Create an application at https://discord.com/developers/applications, +# add the redirect URI below under OAuth2 -> Redirects. +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +DISCORD_REDIRECT_URI=http://localhost:3001/api/auth/discord/callback +# Your Discord user ID. This account is auto-assigned the "owner" role at login. +OWNER_DISCORD_ID= + +# Local development only: set to true to enable POST /api/auth/dev-login, +# which signs you in as a fake owner without Discord credentials. +# Hard-disabled when NODE_ENV=production. +DEV_AUTH_BYPASS=false + +# --- Reforger Workshop API (backend-only, never called from the browser) --- +REFORGER_WORKSHOP_API_BASE_URL=https://api.reforgermods.net + +# --- Pterodactyl (Client API, not Application API) --- +# Leave USE_MOCK_PTERODACTYL=true to run everything locally with mock data. +PTERODACTYL_BASE_URL= +# Create under Account Settings -> API Credentials in Pterodactyl. +PTERODACTYL_CLIENT_API_KEY= +# The short server identifier from the Pterodactyl server URL, e.g. "1a2b3c4d". +PTERODACTYL_SERVER_ID= +USE_MOCK_PTERODACTYL=true + +# --- Reforger config import --- +# Path of the server's config.json in the Pterodactyl file manager. Imported +# read-only to populate the Configuration pages, server name, and max players. +REFORGER_CONFIG_PATH=/config.json +REFORGER_CONFIG_SYNC_INTERVAL_SECONDS=300 + +# --- Reforger log ingestion --- +# Recommended: set REFORGER_LOG_DIRECTORY (e.g. /profile/logs) and the panel +# follows the newest logs_* dated subfolder automatically on every sync. +# REFORGER_ADMIN_LOG_PATH pins one exact file and overrides discovery. +REFORGER_ADMIN_LOG_PATH= +REFORGER_LOG_DIRECTORY= +REFORGER_LOG_FILE_PATTERN=console.log +REFORGER_LOG_POLL_INTERVAL_SECONDS=20 +REFORGER_LOG_MAX_DOWNLOAD_BYTES=2097152 +REFORGER_LOG_STALE_AFTER_SECONDS=90 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c11d1c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +build/ +*.log +.env +.env.local +.env.*.local +coverage/ +.DS_Store +*.tsbuildinfo diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c44611a --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +build/ +coverage/ +apps/api/drizzle/ +package-lock.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..4cbc711 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2 +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e994bc8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# Build stage: compile the API bundle and the web app. +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +COPY apps/api/package.json apps/api/ +COPY apps/web/package.json apps/web/ +COPY packages/shared/package.json packages/shared/ +RUN npm ci --no-audit --no-fund +COPY . . +RUN npm run build + +# Runtime stage: production dependencies + built artifacts only. +FROM node:22-alpine +ENV NODE_ENV=production +WORKDIR /app +COPY package.json package-lock.json ./ +COPY apps/api/package.json apps/api/ +COPY apps/web/package.json apps/web/ +COPY packages/shared/package.json packages/shared/ +RUN npm ci --omit=dev --no-audit --no-fund && npm cache clean --force + +COPY --from=build /app/apps/api/dist apps/api/dist +COPY --from=build /app/apps/api/drizzle apps/api/drizzle +COPY --from=build /app/apps/web/dist apps/web/dist + +ENV WEB_DIST_PATH=/app/apps/web/dist +WORKDIR /app/apps/api +EXPOSE 3001 +# Apply migrations, ensure the server row exists, then start. +CMD ["sh", "-c", "node dist/migrate.js && node dist/seed.js && node dist/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d20f74b --- /dev/null +++ b/README.md @@ -0,0 +1,183 @@ +# Reforger Panel + +A private, purpose-built control panel for one community Arma Reforger training/recruiting server. It sits **on top of Pterodactyl** — Pterodactyl (and Wings) keep running the game container, files, backups, and allocations; this panel is the curated management experience for the owner and trusted crew admins. + +Not included by design: billing, public sign-up, multi-tenancy, arbitrary file management, raw console access, or anything that replaces Pterodactyl. + +## Architecture + +```text +Browser (React SPA) + │ same-origin /api only — no upstream credentials ever reach the browser + ▼ +Panel API (Express + TypeScript) + ├─ Discord OAuth → local users, sessions (Postgres), roles + ├─ PostgreSQL (Drizzle ORM) + ├─ Workshop client → https://api.reforgermods.net (backend-only) + ├─ GameServerProvider abstraction + │ ├─ PterodactylProvider (Client API: status, resources, power, read-only files) + │ └─ MockGameServerProvider (full local dev without credentials) + └─ Log ingestion worker: Pterodactyl log download → parser → players/sessions/events +``` + +Monorepo layout: + +```text +apps/api Express API, Drizzle schema/migrations, ingestion worker, tests +apps/web Vite + React + Tailwind dashboard +packages/shared Roles/capabilities, DTO types, Reforger config model +``` + +## Quick start (mock mode, no Pterodactyl or Discord needed) + +```bash +cp .env.example .env # defaults are fine for local dev +# set DEV_AUTH_BYPASS=true in .env to log in without Discord + +docker compose up -d # Postgres on 127.0.0.1:5433 +npm install +npm run db:migrate +npm run db:seed # creates the server row (real data is imported from the server) +npm run dev # API on :3001, web on :5173 +``` + +Open http://localhost:5173 and use **Local development login** (requires `DEV_AUTH_BYPASS=true`; the endpoint refuses to exist in production). Mock mode serves a generated `console.log`, so within ~20 s the dashboard shows players, sessions, and events produced by the real ingestion pipeline. + +Useful scripts: `npm run lint`, `npm run typecheck`, `npm test`, `npm run build`, `npm run format`, `npm run db:generate` (new migration after schema changes). + +## Environment variables + +See `.env.example` for the full annotated list. Highlights: + +| Variable | Purpose | +| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `DATABASE_URL`, `SESSION_SECRET` | Postgres + cookie/state signing (32+ chars) | +| `DISCORD_CLIENT_ID/SECRET`, `DISCORD_REDIRECT_URI` | Discord OAuth app | +| `OWNER_DISCORD_ID` | This Discord account is auto-assigned `owner` at login | +| `DEV_AUTH_BYPASS` | Local-only fake owner login; rejected when `NODE_ENV=production` | +| `REFORGER_WORKSHOP_API_BASE_URL` | Workshop metadata API (backend-only) | +| `PTERODACTYL_BASE_URL`, `PTERODACTYL_CLIENT_API_KEY`, `PTERODACTYL_SERVER_ID` | Client API (not Application API) | +| `USE_MOCK_PTERODACTYL` | `true` = run entirely against the in-process mock | +| `REFORGER_CONFIG_PATH`, `REFORGER_CONFIG_SYNC_INTERVAL_SECONDS` | Where the server's config.json lives; imported read-only at startup + interval | +| `REFORGER_LOG_DIRECTORY`, `REFORGER_LOG_FILE_PATTERN` | Recommended: directory listed each sync; newest `logs_*` subfolder is followed | +| `REFORGER_ADMIN_LOG_PATH` | Optional: pins one exact log file, overriding directory discovery | +| `REFORGER_LOG_POLL_INTERVAL_SECONDS` / `_MAX_DOWNLOAD_BYTES` / `_STALE_AFTER_SECONDS` | Ingestion pacing, download cap, staleness threshold | + +Environment is validated with zod at startup; the process refuses to boot with missing/contradictory settings (e.g. real mode without Pterodactyl credentials). + +## Users, roles, and enforcement + +Roles live in the panel database (not Discord roles). New users default to `viewer`; the account matching `OWNER_DISCORD_ID` becomes `owner` automatically. The owner manages roles under **Settings**. + +| Capability | owner | server_admin | mission_lead | viewer | +| -------------------------------------- | :---: | :----------: | :----------: | :----: | +| View dashboard/server/players/activity | ✓ | ✓ | ✓ | ✓ | +| Start / stop | ✓ | ✓ | — | — | +| Restart | ✓ | ✓ | ✓ | — | +| Operational health diagnostics | ✓ | ✓ | — | — | +| Manual log sync | ✓ | — | — | — | +| User/role management, settings | ✓ | — | — | — | + +Enforcement is backend middleware (`requireAuth` + `requireCapability`); the frontend only hides buttons. Sessions are 7-day HTTP-only cookies (`SameSite=Lax`, `Secure` in production), stored in Postgres as SHA-256 hashes and revocable server-side. State-changing requests additionally require a custom `X-CSRF-Protection` header and pass an Origin allowlist; OAuth uses a signed `state` cookie. Auth, power, and sync endpoints are rate limited. + +## Log ingestion + +Flow: panel backend → Pterodactyl Client API (signed download URL, streamed with a byte cap) → parser → Postgres. Wings is never touched directly and the browser never downloads logs. + +- **Scheduler** — one poll loop per server (default 20 s), per-server lock so syncs never overlap, exponential backoff (up to 8×) after consecutive failures, graceful shutdown that waits for in-flight syncs. Starts only when a provider and log path are configured. Owner can trigger `POST /api/servers/:slug/logs/sync` manually. +- **Cursoring** — a `log_cursors` row per (server, path) stores byte offset, file fingerprint (hash of the first line when visible), hash of the last processed line, and any partial trailing line. + - _First sync_: only a bounded tail (512 KiB) is imported, never full history. + - _Append_: only bytes after the cursor are parsed; a stored partial line is prepended. + - _Rotation/truncation/replacement_: detected via size decrease, fingerprint change, or a continuity mismatch at the cut point → cursor resets and a bounded tail of the new file is processed. + - _Partial trailing lines_ are never parsed; they wait for the next sync. + - _Large files_: downloads are capped (`REFORGER_LOG_MAX_DOWNLOAD_BYTES`); if the file grew past the window, the gap is noted and a bounded tail is processed. Retrieval is isolated in `pterodactyl-log-source.ts` so range/tail requests can be added without touching parsing. +- **Deduplication** — events carry a unique `(server_id, source_log_path, sha256(raw line))` key enforced by a Postgres unique index, so rotation boundaries and cursor resets cannot double-import. +- **Sessions** — connect opens a session; disconnect closes it with duration; a reconnect without a disconnect closes the stale session (`missed_disconnect`); a fresh server start closes all open sessions (`server_restart`) and emits `server_restart_detected`. + +### Supported log events and known limitations + +Recognized today (patterns centralized in `apps/api/src/modules/reforger-logs/parser/patterns.ts`, verified against real server logs): + +- `Player #N Name (ip:port) connected` (BattlEye wrapper) → `player_connected` +- `Player #N Name disconnected` → `player_disconnected` +- `Player #N Name - BE GUID: …` → merged into the player as a stable identity +- `Authenticated player: … identityId= name=` (BACKEND channel) → engine-level identity, available even without BattlEye +- `Game successfully created` / `Server is ready to accept connections` → `server_started` + +When both identity lines appear for a player, the first one wins and the other is ignored, so a player is never split into duplicates. + +Limitations to keep in mind: + +- **Patterns can change between game versions.** They were validated against a live 2026 server log, but Bohemia can change the format; adjust `patterns.ts` (each pattern has a fixture-backed test). +- **Player identity**: when logs provide no GUID, players are matched by display name only — two people with the same name would merge, and renames create a new player record. The GUID line, when present, upgrades matching to a stable ID. +- **Timestamps** in Reforger logs are time-of-day only; the date comes from the `Log started` header or falls back to the sync date, with midnight-rollover and future-timestamp guards. Cross-midnight logs without a header can be off by a day in pathological cases. +- Player data is **log-polled, not real-time** — the UI always shows "last synchronized" and flags staleness rather than pretending to be live. + +### Finding the log location in Pterodactyl + +Open your server in Pterodactyl → **Files**. Reforger writes a new dated folder per boot (e.g. `/profile/logs/logs_2026-07-04_12-54-04/console.log`). Set `REFORGER_LOG_DIRECTORY` to the parent (e.g. `/profile/logs`) — the panel lists it on every sync and follows the newest `logs_*` folder automatically, so restarts need no reconfiguration. `REFORGER_ADMIN_LOG_PATH` exists to pin one exact file and overrides discovery. + +### Configuration import + +The panel downloads the server's real `config.json` (default `/config.json`, override with `REFORGER_CONFIG_PATH`) at startup, every `REFORGER_CONFIG_SYNC_INTERVAL_SECONDS`, and on demand via **Configurations → Sync from server** (owner/server admin). Each change creates a new `ConfigRevision`, and the server's displayed name and max players always come from the imported config — nothing is hand-seeded. Credentials in config.json (admin password, RCON password) are never copied into the panel's model. + +## Deploying privately (you + friends) + +The API serves the built web app itself in production, so the whole panel is one container plus Postgres: + +```bash +cp .env.example .env # set Discord creds, OWNER_DISCORD_ID, Pterodactyl vars, + # a fresh SESSION_SECRET (openssl rand -base64 32), + # USE_MOCK_PTERODACTYL=false, DEV_AUTH_BYPASS=false +docker compose -f docker-compose.prod.yml up -d --build +``` + +Then put HTTPS in front of port 3001 — any of: + +- **Tailscale** (easiest for a private group): `tailscale serve https / http://localhost:3001`, share the tailnet with your friends. +- **Caddy**: `reverse_proxy localhost:3001` with a domain (automatic HTTPS). +- **nginx + certbot** if you already run it. + +Finally set `WEB_ORIGIN` and `DISCORD_REDIRECT_URI` in `.env` to the public URL (e.g. `https://panel.example.com` and `https://panel.example.com/api/auth/discord/callback`), register that redirect URI in your Discord application, and restart the stack. Production mode enforces `Secure` cookies (HTTPS required), refuses `DEV_AUTH_BYPASS`, and requires Discord credentials at boot. + +Access model for a private group: anyone with the URL can log in with Discord but lands as a **viewer** with read-only access; hand out **invite links** (Settings → Invites) to grant server admin / mission lead roles, and manage roles under Settings → Users. + +## Connecting a real Pterodactyl server safely + +1. In Pterodactyl, log in as a user that has access to **only** this game server (create a dedicated sub-user if needed). +2. Account Settings → API Credentials → create a **Client API** key. This scopes the panel to that user's servers — do not use an admin/Application API key. +3. Set `PTERODACTYL_BASE_URL`, `PTERODACTYL_CLIENT_API_KEY`, `PTERODACTYL_SERVER_ID` (the short identifier from the server URL), `USE_MOCK_PTERODACTYL=false`, and `REFORGER_ADMIN_LOG_PATH`. +4. Update the seeded server row if needed (the seed stores `PTERODACTYL_SERVER_ID` when present). +5. Restart the API and check **Settings → Integrations** and the dashboard's Operational health card. + +The API key stays server-side; requests have 10–30 s timeouts, size-capped downloads, and errors are sanitized (no key, no host, no stack traces) before storage or display. + +## API surface + +```text +GET /api/auth/me POST /api/auth/logout +GET /api/auth/discord GET /api/auth/discord/callback +POST /api/auth/dev-login (dev only) + +GET /api/servers GET /api/servers/:slug +GET /api/servers/:slug/resources GET /api/servers/:slug/players +GET /api/servers/:slug/players/known GET /api/servers/:slug/activity +GET /api/servers/:slug/configuration GET /api/servers/:slug/mod-packs +POST /api/servers/:slug/power/{start,stop,restart} +POST /api/servers/:slug/logs/sync GET /api/servers/:slug/logs/health + +GET /api/workshop/health GET /api/workshop/search?q=&page=&sort= +GET /api/workshop/mods/:id + +GET /api/users PATCH /api/users/:id/role (owner only) +``` + +Errors are structured: `{ "error": { "code", "message", "requestId" } }`. + +## Implemented vs scaffolded + +**Implemented**: Discord OAuth + sessions + role enforcement, dashboard + server pages, provider abstraction with mock and real Pterodactyl Client API, power controls with per-role limits (audited to the activity feed, simulated in mock mode), Workshop health/search/detail proxy, read-only config preview + revision history, full log ingestion pipeline (scheduler, cursoring, rotation, dedupe, sessions), operational health card, owner user/role management, 63 tests. + +**Scaffolded / later phases**: mod-pack editing and deployment ("Add to pack" is intentionally disabled), config generation/writing to the server (no file writes through Pterodactyl yet), config presets for mission leads, Discord-role sync, multi-server support (schema is ready; UI assumes one), historical playtime analytics. + +**Recommended next steps**: (1) capture real `console.log` samples from your server and harden the parser fixtures; (2) mod-pack builder writing `ModPackRevision`s from Workshop search; (3) config generation producing a real `config.json` diff/preview from `ConfigRevision`, then a guarded deploy (file write + restart) for owner/server admin; (4) preset selection for mission leads; (5) session-history charts from `player_sessions`. diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts new file mode 100644 index 0000000..0f1d659 --- /dev/null +++ b/apps/api/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/db/schema.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL ?? 'postgresql://reforger:reforger@127.0.0.1:5433/reforger_panel', + }, +}); diff --git a/apps/api/drizzle/0000_init.sql b/apps/api/drizzle/0000_init.sql new file mode 100644 index 0000000..ccac960 --- /dev/null +++ b/apps/api/drizzle/0000_init.sql @@ -0,0 +1,152 @@ +CREATE TABLE "config_revisions" ( + "id" text PRIMARY KEY NOT NULL, + "server_id" text NOT NULL, + "created_by_user_id" text, + "version" integer NOT NULL, + "summary" text NOT NULL, + "config" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "log_cursors" ( + "id" text PRIMARY KEY NOT NULL, + "server_id" text NOT NULL, + "log_path" text NOT NULL, + "file_fingerprint" text, + "last_byte_offset" bigint DEFAULT 0 NOT NULL, + "last_line_hash" text, + "partial_trailing_line" text, + "last_event_timestamp" timestamp with time zone, + "last_successful_sync_at" timestamp with time zone, + "last_error_at" timestamp with time zone, + "last_error_message" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "mod_pack_revisions" ( + "id" text PRIMARY KEY NOT NULL, + "mod_pack_id" text NOT NULL, + "version" integer NOT NULL, + "notes" text, + "mods" jsonb DEFAULT '[]'::jsonb NOT NULL, + "created_by_user_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "mod_packs" ( + "id" text PRIMARY KEY NOT NULL, + "server_id" text NOT NULL, + "name" text NOT NULL, + "description" text, + "status" text DEFAULT 'draft' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "player_sessions" ( + "id" text PRIMARY KEY NOT NULL, + "server_id" text NOT NULL, + "player_id" text NOT NULL, + "connected_at" timestamp with time zone NOT NULL, + "disconnected_at" timestamp with time zone, + "duration_seconds" integer, + "disconnect_reason" text, + "source_log_path" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "players" ( + "id" text PRIMARY KEY NOT NULL, + "server_id" text NOT NULL, + "external_player_id" text, + "display_name" text NOT NULL, + "first_seen_at" timestamp with time zone NOT NULL, + "last_seen_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "server_activity" ( + "id" text PRIMARY KEY NOT NULL, + "server_id" text NOT NULL, + "actor_user_id" text, + "action" text NOT NULL, + "summary" text NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "server_events" ( + "id" text PRIMARY KEY NOT NULL, + "server_id" text NOT NULL, + "event_type" text NOT NULL, + "occurred_at" timestamp with time zone NOT NULL, + "player_id" text, + "player_session_id" text, + "summary" text NOT NULL, + "payload" jsonb DEFAULT '{}'::jsonb NOT NULL, + "source_log_path" text NOT NULL, + "source_line_hash" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "servers" ( + "id" text PRIMARY KEY NOT NULL, + "slug" text NOT NULL, + "name" text NOT NULL, + "provider_type" text DEFAULT 'pterodactyl' NOT NULL, + "pterodactyl_server_id" text, + "status" text DEFAULT 'unknown' NOT NULL, + "max_players" integer, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sessions" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" text PRIMARY KEY NOT NULL, + "discord_id" text NOT NULL, + "username" text NOT NULL, + "display_name" text, + "avatar_url" text, + "role" text DEFAULT 'viewer' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "config_revisions" ADD CONSTRAINT "config_revisions_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "config_revisions" ADD CONSTRAINT "config_revisions_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "log_cursors" ADD CONSTRAINT "log_cursors_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mod_pack_revisions" ADD CONSTRAINT "mod_pack_revisions_mod_pack_id_mod_packs_id_fk" FOREIGN KEY ("mod_pack_id") REFERENCES "public"."mod_packs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mod_pack_revisions" ADD CONSTRAINT "mod_pack_revisions_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mod_packs" ADD CONSTRAINT "mod_packs_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "player_sessions" ADD CONSTRAINT "player_sessions_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "player_sessions" ADD CONSTRAINT "player_sessions_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "players" ADD CONSTRAINT "players_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "server_activity" ADD CONSTRAINT "server_activity_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "server_activity" ADD CONSTRAINT "server_activity_actor_user_id_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "server_events" ADD CONSTRAINT "server_events_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "server_events" ADD CONSTRAINT "server_events_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "server_events" ADD CONSTRAINT "server_events_player_session_id_player_sessions_id_fk" FOREIGN KEY ("player_session_id") REFERENCES "public"."player_sessions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "config_revisions_server_version_unique" ON "config_revisions" USING btree ("server_id","version");--> statement-breakpoint +CREATE UNIQUE INDEX "log_cursors_server_path_unique" ON "log_cursors" USING btree ("server_id","log_path");--> statement-breakpoint +CREATE UNIQUE INDEX "mod_pack_revisions_pack_version_unique" ON "mod_pack_revisions" USING btree ("mod_pack_id","version");--> statement-breakpoint +CREATE INDEX "player_sessions_server_open_idx" ON "player_sessions" USING btree ("server_id","disconnected_at");--> statement-breakpoint +CREATE INDEX "player_sessions_player_idx" ON "player_sessions" USING btree ("player_id");--> statement-breakpoint +CREATE UNIQUE INDEX "players_server_external_id_unique" ON "players" USING btree ("server_id","external_player_id");--> statement-breakpoint +CREATE INDEX "players_server_name_idx" ON "players" USING btree ("server_id","display_name");--> statement-breakpoint +CREATE INDEX "server_activity_server_created_idx" ON "server_activity" USING btree ("server_id","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "server_events_dedupe_unique" ON "server_events" USING btree ("server_id","source_log_path","source_line_hash");--> statement-breakpoint +CREATE INDEX "server_events_server_occurred_idx" ON "server_events" USING btree ("server_id","occurred_at");--> statement-breakpoint +CREATE UNIQUE INDEX "servers_slug_unique" ON "servers" USING btree ("slug");--> statement-breakpoint +CREATE INDEX "sessions_user_id_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "users_discord_id_unique" ON "users" USING btree ("discord_id"); \ No newline at end of file diff --git a/apps/api/drizzle/0001_invites.sql b/apps/api/drizzle/0001_invites.sql new file mode 100644 index 0000000..9dd20b7 --- /dev/null +++ b/apps/api/drizzle/0001_invites.sql @@ -0,0 +1,14 @@ +CREATE TABLE "invites" ( + "id" text PRIMARY KEY NOT NULL, + "code" text NOT NULL, + "role" text DEFAULT 'viewer' NOT NULL, + "created_by_user_id" text, + "expires_at" timestamp with time zone NOT NULL, + "used_by_user_id" text, + "used_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "invites" ADD CONSTRAINT "invites_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invites" ADD CONSTRAINT "invites_used_by_user_id_users_id_fk" FOREIGN KEY ("used_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "invites_code_unique" ON "invites" USING btree ("code"); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0000_snapshot.json b/apps/api/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..c7e2bcf --- /dev/null +++ b/apps/api/drizzle/meta/0000_snapshot.json @@ -0,0 +1,1142 @@ +{ + "id": "0e074f08-6960-4e20-b579-f27574818246", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.config_revisions": { + "name": "config_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "config_revisions_server_version_unique": { + "name": "config_revisions_server_version_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "config_revisions_server_id_servers_id_fk": { + "name": "config_revisions_server_id_servers_id_fk", + "tableFrom": "config_revisions", + "tableTo": "servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "config_revisions_created_by_user_id_users_id_fk": { + "name": "config_revisions_created_by_user_id_users_id_fk", + "tableFrom": "config_revisions", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log_cursors": { + "name": "log_cursors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "log_path": { + "name": "log_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_fingerprint": { + "name": "file_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_byte_offset": { + "name": "last_byte_offset", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_line_hash": { + "name": "last_line_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "partial_trailing_line": { + "name": "partial_trailing_line", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_event_timestamp": { + "name": "last_event_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_successful_sync_at": { + "name": "last_successful_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "log_cursors_server_path_unique": { + "name": "log_cursors_server_path_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "log_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "log_cursors_server_id_servers_id_fk": { + "name": "log_cursors_server_id_servers_id_fk", + "tableFrom": "log_cursors", + "tableTo": "servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mod_pack_revisions": { + "name": "mod_pack_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mod_pack_id": { + "name": "mod_pack_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mods": { + "name": "mods", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mod_pack_revisions_pack_version_unique": { + "name": "mod_pack_revisions_pack_version_unique", + "columns": [ + { + "expression": "mod_pack_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mod_pack_revisions_mod_pack_id_mod_packs_id_fk": { + "name": "mod_pack_revisions_mod_pack_id_mod_packs_id_fk", + "tableFrom": "mod_pack_revisions", + "tableTo": "mod_packs", + "columnsFrom": ["mod_pack_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mod_pack_revisions_created_by_user_id_users_id_fk": { + "name": "mod_pack_revisions_created_by_user_id_users_id_fk", + "tableFrom": "mod_pack_revisions", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mod_packs": { + "name": "mod_packs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mod_packs_server_id_servers_id_fk": { + "name": "mod_packs_server_id_servers_id_fk", + "tableFrom": "mod_packs", + "tableTo": "servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.player_sessions": { + "name": "player_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "disconnect_reason": { + "name": "disconnect_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_log_path": { + "name": "source_log_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "player_sessions_server_open_idx": { + "name": "player_sessions_server_open_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "disconnected_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "player_sessions_player_idx": { + "name": "player_sessions_player_idx", + "columns": [ + { + "expression": "player_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "player_sessions_server_id_servers_id_fk": { + "name": "player_sessions_server_id_servers_id_fk", + "tableFrom": "player_sessions", + "tableTo": "servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "player_sessions_player_id_players_id_fk": { + "name": "player_sessions_player_id_players_id_fk", + "tableFrom": "player_sessions", + "tableTo": "players", + "columnsFrom": ["player_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.players": { + "name": "players", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_player_id": { + "name": "external_player_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "players_server_external_id_unique": { + "name": "players_server_external_id_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_player_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "players_server_name_idx": { + "name": "players_server_name_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "players_server_id_servers_id_fk": { + "name": "players_server_id_servers_id_fk", + "tableFrom": "players", + "tableTo": "servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_activity": { + "name": "server_activity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_activity_server_created_idx": { + "name": "server_activity_server_created_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_activity_server_id_servers_id_fk": { + "name": "server_activity_server_id_servers_id_fk", + "tableFrom": "server_activity", + "tableTo": "servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_activity_actor_user_id_users_id_fk": { + "name": "server_activity_actor_user_id_users_id_fk", + "tableFrom": "server_activity", + "tableTo": "users", + "columnsFrom": ["actor_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_events": { + "name": "server_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "player_session_id": { + "name": "player_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source_log_path": { + "name": "source_log_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_line_hash": { + "name": "source_line_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_events_dedupe_unique": { + "name": "server_events_dedupe_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_log_path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_line_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_events_server_occurred_idx": { + "name": "server_events_server_occurred_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_events_server_id_servers_id_fk": { + "name": "server_events_server_id_servers_id_fk", + "tableFrom": "server_events", + "tableTo": "servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_events_player_id_players_id_fk": { + "name": "server_events_player_id_players_id_fk", + "tableFrom": "server_events", + "tableTo": "players", + "columnsFrom": ["player_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "server_events_player_session_id_player_sessions_id_fk": { + "name": "server_events_player_session_id_player_sessions_id_fk", + "tableFrom": "server_events", + "tableTo": "player_sessions", + "columnsFrom": ["player_session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pterodactyl'" + }, + "pterodactyl_server_id": { + "name": "pterodactyl_server_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "max_players": { + "name": "max_players", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "servers_slug_unique": { + "name": "servers_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_discord_id_unique": { + "name": "users_discord_id_unique", + "columns": [ + { + "expression": "discord_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/api/drizzle/meta/0001_snapshot.json b/apps/api/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..3982732 --- /dev/null +++ b/apps/api/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1308 @@ +{ + "id": "f35ba9b3-1522-4c28-97dc-bc03850bfe0a", + "prevId": "0e074f08-6960-4e20-b579-f27574818246", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.config_revisions": { + "name": "config_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "config_revisions_server_version_unique": { + "name": "config_revisions_server_version_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "config_revisions_server_id_servers_id_fk": { + "name": "config_revisions_server_id_servers_id_fk", + "tableFrom": "config_revisions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "config_revisions_created_by_user_id_users_id_fk": { + "name": "config_revisions_created_by_user_id_users_id_fk", + "tableFrom": "config_revisions", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_by_user_id": { + "name": "used_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invites_code_unique": { + "name": "invites_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invites_created_by_user_id_users_id_fk": { + "name": "invites_created_by_user_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "invites_used_by_user_id_users_id_fk": { + "name": "invites_used_by_user_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "used_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log_cursors": { + "name": "log_cursors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "log_path": { + "name": "log_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_fingerprint": { + "name": "file_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_byte_offset": { + "name": "last_byte_offset", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_line_hash": { + "name": "last_line_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "partial_trailing_line": { + "name": "partial_trailing_line", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_event_timestamp": { + "name": "last_event_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_successful_sync_at": { + "name": "last_successful_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "log_cursors_server_path_unique": { + "name": "log_cursors_server_path_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "log_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "log_cursors_server_id_servers_id_fk": { + "name": "log_cursors_server_id_servers_id_fk", + "tableFrom": "log_cursors", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mod_pack_revisions": { + "name": "mod_pack_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mod_pack_id": { + "name": "mod_pack_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mods": { + "name": "mods", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mod_pack_revisions_pack_version_unique": { + "name": "mod_pack_revisions_pack_version_unique", + "columns": [ + { + "expression": "mod_pack_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mod_pack_revisions_mod_pack_id_mod_packs_id_fk": { + "name": "mod_pack_revisions_mod_pack_id_mod_packs_id_fk", + "tableFrom": "mod_pack_revisions", + "tableTo": "mod_packs", + "columnsFrom": [ + "mod_pack_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mod_pack_revisions_created_by_user_id_users_id_fk": { + "name": "mod_pack_revisions_created_by_user_id_users_id_fk", + "tableFrom": "mod_pack_revisions", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mod_packs": { + "name": "mod_packs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mod_packs_server_id_servers_id_fk": { + "name": "mod_packs_server_id_servers_id_fk", + "tableFrom": "mod_packs", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.player_sessions": { + "name": "player_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "disconnect_reason": { + "name": "disconnect_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_log_path": { + "name": "source_log_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "player_sessions_server_open_idx": { + "name": "player_sessions_server_open_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "disconnected_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "player_sessions_player_idx": { + "name": "player_sessions_player_idx", + "columns": [ + { + "expression": "player_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "player_sessions_server_id_servers_id_fk": { + "name": "player_sessions_server_id_servers_id_fk", + "tableFrom": "player_sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "player_sessions_player_id_players_id_fk": { + "name": "player_sessions_player_id_players_id_fk", + "tableFrom": "player_sessions", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.players": { + "name": "players", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_player_id": { + "name": "external_player_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "players_server_external_id_unique": { + "name": "players_server_external_id_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_player_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "players_server_name_idx": { + "name": "players_server_name_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "players_server_id_servers_id_fk": { + "name": "players_server_id_servers_id_fk", + "tableFrom": "players", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_activity": { + "name": "server_activity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_activity_server_created_idx": { + "name": "server_activity_server_created_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_activity_server_id_servers_id_fk": { + "name": "server_activity_server_id_servers_id_fk", + "tableFrom": "server_activity", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_activity_actor_user_id_users_id_fk": { + "name": "server_activity_actor_user_id_users_id_fk", + "tableFrom": "server_activity", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_events": { + "name": "server_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "player_session_id": { + "name": "player_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source_log_path": { + "name": "source_log_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_line_hash": { + "name": "source_line_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_events_dedupe_unique": { + "name": "server_events_dedupe_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_log_path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_line_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_events_server_occurred_idx": { + "name": "server_events_server_occurred_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_events_server_id_servers_id_fk": { + "name": "server_events_server_id_servers_id_fk", + "tableFrom": "server_events", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_events_player_id_players_id_fk": { + "name": "server_events_player_id_players_id_fk", + "tableFrom": "server_events", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "server_events_player_session_id_player_sessions_id_fk": { + "name": "server_events_player_session_id_player_sessions_id_fk", + "tableFrom": "server_events", + "tableTo": "player_sessions", + "columnsFrom": [ + "player_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pterodactyl'" + }, + "pterodactyl_server_id": { + "name": "pterodactyl_server_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "max_players": { + "name": "max_players", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "servers_slug_unique": { + "name": "servers_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_discord_id_unique": { + "name": "users_discord_id_unique", + "columns": [ + { + "expression": "discord_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json new file mode 100644 index 0000000..4d9ed0d --- /dev/null +++ b/apps/api/drizzle/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1783229645037, + "tag": "0000_init", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1783279251170, + "tag": "0001_invites", + "breakpoints": true + } + ] +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..f0efc81 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,37 @@ +{ + "name": "@reforger-panel/api", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch --env-file-if-exists=../../.env src/index.ts", + "build": "tsup", + "start": "node --env-file-if-exists=../../.env dist/index.js", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx --env-file-if-exists=../../.env src/db/migrate.ts", + "db:seed": "tsx --env-file-if-exists=../../.env src/db/seed.ts" + }, + "dependencies": { + "@reforger-panel/shared": "*", + "cookie": "^1.0.2", + "drizzle-orm": "^0.44.0", + "express": "^5.1.0", + "pg": "^8.16.0", + "pino": "^9.7.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/node": "^24.0.0", + "@types/pg": "^8.15.0", + "@types/supertest": "^6.0.0", + "drizzle-kit": "^0.31.0", + "pino-pretty": "^13.0.0", + "supertest": "^7.1.0", + "tsup": "^8.5.0", + "tsx": "^4.20.0", + "vitest": "^3.2.0" + } +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..22120ce --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,166 @@ +import express from 'express'; +import type { NextFunction, Request, Response } from 'express'; +import { randomUUID } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import type { ApiErrorBody } from '@reforger-panel/shared'; +import type { Env } from './env.js'; +import type { Db } from './db/client.js'; +import { ApiError } from './lib/errors.js'; +import type { Logger } from './lib/logger.js'; +import { createAuthRouter } from './modules/auth/auth-routes.js'; +import { csrfProtection, sessionResolver } from './modules/auth/auth-middleware.js'; +import type { SessionService } from './modules/auth/session-service.js'; +import type { ConfigSyncService } from './modules/config/config-sync.js'; +import type { ServerModsService } from './modules/config/mods-service.js'; +import type { PerformanceSettingsService } from './modules/config/performance-service.js'; +import type { ResourceHistoryService } from './modules/servers/resource-history.js'; +import type { MissionCatalog } from './modules/reforger-logs/missions-catalog.js'; +import type { GameServerProvider } from './modules/pterodactyl/types.js'; +import type { LogPathResolver } from './modules/reforger-logs/ingestion/log-path-resolver.js'; +import type { IngestionScheduler } from './modules/reforger-logs/ingestion/scheduler.js'; +import { createServerRouter } from './modules/servers/server-routes.js'; +import type { ServerService } from './modules/servers/server-service.js'; +import { createInviteRouter } from './modules/invites/invite-routes.js'; +import { createUserRouter } from './modules/users/user-routes.js'; +import { createWorkshopRouter } from './modules/workshop/workshop-routes.js'; +import type { WorkshopClient } from './modules/workshop/workshop-client.js'; + +export type AppDeps = { + env: Env; + logger: Logger; + db: Db; + sessions: SessionService; + servers: ServerService; + provider: GameServerProvider; + workshop: WorkshopClient; + scheduler: IngestionScheduler | null; + resolveLogPath: LogPathResolver | null; + configSync: ConfigSyncService | null; + mods: ServerModsService | null; + performance: PerformanceSettingsService | null; + resourceHistory: ResourceHistoryService | null; + missions: MissionCatalog | null; +}; + +export function createApp(deps: AppDeps) { + const { env, logger } = deps; + const app = express(); + + app.disable('x-powered-by'); + app.set('trust proxy', 1); + app.use(express.json({ limit: '64kb' })); + + // Request id + minimal structured request logging. + app.use((req, res, next) => { + const requestId = randomUUID(); + res.locals.requestId = requestId; + res.setHeader('X-Request-Id', requestId); + res.on('finish', () => { + logger.debug( + { requestId, method: req.method, path: req.path, status: res.statusCode }, + 'request', + ); + }); + next(); + }); + + // Safe CORS default: only the configured web origin, with credentials. + const allowedOrigin = env.WEB_ORIGIN.replace(/\/$/, ''); + app.use((req, res, next) => { + const origin = req.headers.origin; + if (origin && origin.replace(/\/$/, '') === allowedOrigin) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Vary', 'Origin'); + } + if (req.method === 'OPTIONS') { + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type,X-CSRF-Protection'); + res.status(204).end(); + return; + } + next(); + }); + + app.use(sessionResolver(deps.sessions)); + + // CSRF protection for all state-changing requests. The OAuth callback is a + // GET and is protected by the signed state parameter instead. + const csrf = csrfProtection([allowedOrigin, `http://localhost:${env.PORT}`]); + app.use('/api', (req, res, next) => { + if (req.method === 'GET' || req.method === 'HEAD') { + next(); + return; + } + csrf(req, res, next); + }); + + app.get('/api/health', (_req, res) => { + res.json({ ok: true }); + }); + + app.use('/api/auth', createAuthRouter(env, deps.sessions)); + app.use('/api/users', createUserRouter(deps.db)); + app.use('/api/invites', createInviteRouter(deps.db)); + app.use( + '/api/servers', + createServerRouter({ + service: deps.servers, + provider: deps.provider, + scheduler: deps.scheduler, + resolveLogPath: deps.resolveLogPath, + configSync: deps.configSync, + mods: deps.mods, + performance: deps.performance, + resourceHistory: deps.resourceHistory, + missions: deps.missions, + workshop: deps.workshop, + staleAfterSeconds: env.REFORGER_LOG_STALE_AFTER_SECONDS, + mockMode: env.USE_MOCK_PTERODACTYL, + }), + ); + app.use('/api/workshop', createWorkshopRouter(deps.workshop)); + + app.use('/api', (_req, _res, next) => { + next(ApiError.notFound('Unknown API route.')); + }); + + // Production: serve the built SPA from the same process (no separate web + // server needed). In development Vite serves the frontend instead. + const webDist = env.WEB_DIST_PATH || path.resolve(process.cwd(), '../web/dist'); + const webIndex = path.join(webDist, 'index.html'); + if (env.NODE_ENV === 'production' && existsSync(webIndex)) { + app.use(express.static(webDist, { index: false, maxAge: '1h' })); + app.use((req, res, next) => { + if (req.method !== 'GET' || req.path.startsWith('/api')) { + next(); + return; + } + res.sendFile(webIndex); + }); + logger.info({ webDist }, 'serving web app from API process'); + } + + // Structured error responses; internals are logged, never sent to clients. + app.use((error: unknown, req: Request, res: Response, _next: NextFunction) => { + const requestId = String(res.locals.requestId ?? ''); + if (error instanceof ApiError) { + const body: ApiErrorBody = { + error: { code: error.code, message: error.message, requestId }, + }; + res.status(error.status).json(body); + return; + } + logger.error( + { requestId, path: req.path, err: error instanceof Error ? error.message : String(error) }, + 'unhandled error', + ); + const body: ApiErrorBody = { + error: { code: 'INTERNAL_ERROR', message: 'Something went wrong.', requestId }, + }; + res.status(500).json(body); + }); + + return app; +} diff --git a/apps/api/src/db/client.ts b/apps/api/src/db/client.ts new file mode 100644 index 0000000..5c82351 --- /dev/null +++ b/apps/api/src/db/client.ts @@ -0,0 +1,12 @@ +import { drizzle } from 'drizzle-orm/node-postgres'; +import pg from 'pg'; +import * as schema from './schema.js'; + +export function createDb(databaseUrl: string) { + const pool = new pg.Pool({ connectionString: databaseUrl, max: 10 }); + const db = drizzle(pool, { schema }); + return { db, pool }; +} + +export type Db = ReturnType['db']; +export { schema }; diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts new file mode 100644 index 0000000..198c16c --- /dev/null +++ b/apps/api/src/db/migrate.ts @@ -0,0 +1,17 @@ +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import path from 'node:path'; +import { createDb } from './client.js'; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) { + console.error('DATABASE_URL is not set'); + process.exit(1); +} + +// Run from apps/api (the npm scripts do this); migrations live in ./drizzle. +const migrationsFolder = path.resolve(process.cwd(), 'drizzle'); + +const { db, pool } = createDb(databaseUrl); +await migrate(db, { migrationsFolder }); +await pool.end(); +console.log('Migrations applied.'); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts new file mode 100644 index 0000000..41871ec --- /dev/null +++ b/apps/api/src/db/schema.ts @@ -0,0 +1,247 @@ +import { + bigint, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + uniqueIndex, +} from 'drizzle-orm/pg-core'; +import { randomUUID } from 'node:crypto'; + +const id = () => + text('id') + .primaryKey() + .$defaultFn(() => randomUUID()); + +const createdAt = () => timestamp('created_at', { withTimezone: true }).notNull().defaultNow(); +const updatedAt = () => + timestamp('updated_at', { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdateFn(() => new Date()); + +export const users = pgTable( + 'users', + { + id: id(), + discordId: text('discord_id').notNull(), + username: text('username').notNull(), + displayName: text('display_name'), + avatarUrl: text('avatar_url'), + role: text('role', { enum: ['owner', 'server_admin', 'mission_lead', 'viewer'] }) + .notNull() + .default('viewer'), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (t) => [uniqueIndex('users_discord_id_unique').on(t.discordId)], +); + +export const sessions = pgTable( + 'sessions', + { + // sha256 hash of the cookie token; the raw token is never stored. + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + createdAt: createdAt(), + }, + (t) => [index('sessions_user_id_idx').on(t.userId)], +); + +export const invites = pgTable( + 'invites', + { + id: id(), + code: text('code').notNull(), + role: text('role', { enum: ['owner', 'server_admin', 'mission_lead', 'viewer'] }) + .notNull() + .default('viewer'), + createdByUserId: text('created_by_user_id').references(() => users.id, { + onDelete: 'set null', + }), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + usedByUserId: text('used_by_user_id').references(() => users.id, { onDelete: 'set null' }), + usedAt: timestamp('used_at', { withTimezone: true }), + createdAt: createdAt(), + }, + (t) => [uniqueIndex('invites_code_unique').on(t.code)], +); + +export const servers = pgTable( + 'servers', + { + id: id(), + slug: text('slug').notNull(), + name: text('name').notNull(), + providerType: text('provider_type').notNull().default('pterodactyl'), + pterodactylServerId: text('pterodactyl_server_id'), + status: text('status').notNull().default('unknown'), + maxPlayers: integer('max_players'), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (t) => [uniqueIndex('servers_slug_unique').on(t.slug)], +); + +export const serverActivity = pgTable( + 'server_activity', + { + id: id(), + serverId: text('server_id') + .notNull() + .references(() => servers.id, { onDelete: 'cascade' }), + actorUserId: text('actor_user_id').references(() => users.id, { onDelete: 'set null' }), + action: text('action').notNull(), + summary: text('summary').notNull(), + metadata: jsonb('metadata').notNull().default({}), + createdAt: createdAt(), + }, + (t) => [index('server_activity_server_created_idx').on(t.serverId, t.createdAt)], +); + +export const modPacks = pgTable('mod_packs', { + id: id(), + serverId: text('server_id') + .notNull() + .references(() => servers.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + description: text('description'), + status: text('status').notNull().default('draft'), + createdAt: createdAt(), + updatedAt: updatedAt(), +}); + +export const modPackRevisions = pgTable( + 'mod_pack_revisions', + { + id: id(), + modPackId: text('mod_pack_id') + .notNull() + .references(() => modPacks.id, { onDelete: 'cascade' }), + version: integer('version').notNull(), + notes: text('notes'), + mods: jsonb('mods').notNull().default([]), + createdByUserId: text('created_by_user_id').references(() => users.id, { + onDelete: 'set null', + }), + createdAt: createdAt(), + }, + (t) => [uniqueIndex('mod_pack_revisions_pack_version_unique').on(t.modPackId, t.version)], +); + +export const configRevisions = pgTable( + 'config_revisions', + { + id: id(), + serverId: text('server_id') + .notNull() + .references(() => servers.id, { onDelete: 'cascade' }), + createdByUserId: text('created_by_user_id').references(() => users.id, { + onDelete: 'set null', + }), + version: integer('version').notNull(), + summary: text('summary').notNull(), + config: jsonb('config').notNull(), + createdAt: createdAt(), + }, + (t) => [uniqueIndex('config_revisions_server_version_unique').on(t.serverId, t.version)], +); + +export const logCursors = pgTable( + 'log_cursors', + { + id: id(), + serverId: text('server_id') + .notNull() + .references(() => servers.id, { onDelete: 'cascade' }), + logPath: text('log_path').notNull(), + fileFingerprint: text('file_fingerprint'), + lastByteOffset: bigint('last_byte_offset', { mode: 'number' }).notNull().default(0), + lastLineHash: text('last_line_hash'), + partialTrailingLine: text('partial_trailing_line'), + lastEventTimestamp: timestamp('last_event_timestamp', { withTimezone: true }), + lastSuccessfulSyncAt: timestamp('last_successful_sync_at', { withTimezone: true }), + lastErrorAt: timestamp('last_error_at', { withTimezone: true }), + lastErrorMessage: text('last_error_message'), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (t) => [uniqueIndex('log_cursors_server_path_unique').on(t.serverId, t.logPath)], +); + +export const players = pgTable( + 'players', + { + id: id(), + serverId: text('server_id') + .notNull() + .references(() => servers.id, { onDelete: 'cascade' }), + // Stable identity from logs (e.g. a GUID) when available. Display names are + // NOT unique identities; see README for the fallback limitations. + externalPlayerId: text('external_player_id'), + displayName: text('display_name').notNull(), + firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).notNull(), + lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull(), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (t) => [ + uniqueIndex('players_server_external_id_unique').on(t.serverId, t.externalPlayerId), + index('players_server_name_idx').on(t.serverId, t.displayName), + ], +); + +export const playerSessions = pgTable( + 'player_sessions', + { + id: id(), + serverId: text('server_id') + .notNull() + .references(() => servers.id, { onDelete: 'cascade' }), + playerId: text('player_id') + .notNull() + .references(() => players.id, { onDelete: 'cascade' }), + connectedAt: timestamp('connected_at', { withTimezone: true }).notNull(), + disconnectedAt: timestamp('disconnected_at', { withTimezone: true }), + durationSeconds: integer('duration_seconds'), + disconnectReason: text('disconnect_reason'), + sourceLogPath: text('source_log_path').notNull(), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (t) => [ + index('player_sessions_server_open_idx').on(t.serverId, t.disconnectedAt), + index('player_sessions_player_idx').on(t.playerId), + ], +); + +export const serverEvents = pgTable( + 'server_events', + { + id: id(), + serverId: text('server_id') + .notNull() + .references(() => servers.id, { onDelete: 'cascade' }), + eventType: text('event_type').notNull(), + occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(), + playerId: text('player_id').references(() => players.id, { onDelete: 'set null' }), + playerSessionId: text('player_session_id').references(() => playerSessions.id, { + onDelete: 'set null', + }), + summary: text('summary').notNull(), + payload: jsonb('payload').notNull().default({}), + sourceLogPath: text('source_log_path').notNull(), + sourceLineHash: text('source_line_hash').notNull(), + createdAt: createdAt(), + }, + (t) => [ + // Event deduplication key: one event per (server, log file, line fingerprint). + uniqueIndex('server_events_dedupe_unique').on(t.serverId, t.sourceLogPath, t.sourceLineHash), + index('server_events_server_occurred_idx').on(t.serverId, t.occurredAt), + ], +); diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts new file mode 100644 index 0000000..dceb559 --- /dev/null +++ b/apps/api/src/db/seed.ts @@ -0,0 +1,47 @@ +import { eq } from 'drizzle-orm'; +import { createDb, schema } from './client.js'; + +// Seeds only the server row. Everything else (configuration, players, +// sessions, events, activity) comes from the real server: config revisions are +// imported from config.json and player data from log ingestion. No fake data. + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) { + console.error('DATABASE_URL is not set'); + process.exit(1); +} + +const { db, pool } = createDb(databaseUrl); + +const existing = await db + .select() + .from(schema.servers) + .where(eq(schema.servers.slug, 'training-server')); + +const pterodactylServerId = process.env.PTERODACTYL_SERVER_ID || null; + +if (existing[0]) { + if (pterodactylServerId && existing[0].pterodactylServerId !== pterodactylServerId) { + await db + .update(schema.servers) + .set({ pterodactylServerId }) + .where(eq(schema.servers.id, existing[0].id)); + console.log(`Updated pterodactylServerId to ${pterodactylServerId}.`); + } else { + console.log('Server "training-server" already exists; nothing to do.'); + } +} else { + await db.insert(schema.servers).values({ + slug: 'training-server', + // Placeholder until the first config.json import overwrites it. + name: 'Reforger Server', + providerType: 'pterodactyl', + pterodactylServerId, + status: 'unknown', + maxPlayers: null, + }); + console.log('Seeded server row (name/maxPlayers will be imported from config.json).'); +} + +await pool.end(); +console.log('Seed complete.'); diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts new file mode 100644 index 0000000..2f807c1 --- /dev/null +++ b/apps/api/src/env.ts @@ -0,0 +1,105 @@ +import { z } from 'zod'; + +const booleanString = z + .enum(['true', 'false']) + .default('false') + .transform((v) => v === 'true'); + +const envSchema = z + .object({ + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + PORT: z.coerce.number().int().min(1).max(65535).default(3001), + WEB_ORIGIN: z.string().url().default('http://localhost:5173'), + + DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'), + /** Directory of the built web app; when it exists the API serves it. */ + WEB_DIST_PATH: z.string().default(''), + SESSION_SECRET: z.string().min(32, 'SESSION_SECRET must be at least 32 characters'), + + DISCORD_CLIENT_ID: z.string().default(''), + DISCORD_CLIENT_SECRET: z.string().default(''), + DISCORD_REDIRECT_URI: z.string().default('http://localhost:3001/api/auth/discord/callback'), + OWNER_DISCORD_ID: z.string().default(''), + DEV_AUTH_BYPASS: booleanString, + + REFORGER_WORKSHOP_API_BASE_URL: z.string().url().default('https://api.reforgermods.net'), + + PTERODACTYL_BASE_URL: z.string().default(''), + PTERODACTYL_CLIENT_API_KEY: z.string().default(''), + PTERODACTYL_SERVER_ID: z.string().default(''), + USE_MOCK_PTERODACTYL: booleanString, + + REFORGER_CONFIG_PATH: z.string().default('/config.json'), + REFORGER_CONFIG_SYNC_INTERVAL_SECONDS: z.coerce.number().int().min(60).max(86400).default(300), + + REFORGER_ADMIN_LOG_PATH: z.string().default(''), + REFORGER_LOG_DIRECTORY: z.string().default(''), + REFORGER_LOG_FILE_PATTERN: z.string().default(''), + REFORGER_LOG_POLL_INTERVAL_SECONDS: z.coerce.number().int().min(5).max(3600).default(20), + REFORGER_LOG_MAX_DOWNLOAD_BYTES: z.coerce + .number() + .int() + .min(64 * 1024) + .max(64 * 1024 * 1024) + .default(2 * 1024 * 1024), + REFORGER_LOG_STALE_AFTER_SECONDS: z.coerce.number().int().min(30).default(90), + }) + .superRefine((env, ctx) => { + if (env.NODE_ENV === 'production' && env.DEV_AUTH_BYPASS) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'DEV_AUTH_BYPASS must not be enabled in production', + path: ['DEV_AUTH_BYPASS'], + }); + } + if (!env.USE_MOCK_PTERODACTYL) { + for (const key of [ + 'PTERODACTYL_BASE_URL', + 'PTERODACTYL_CLIENT_API_KEY', + 'PTERODACTYL_SERVER_ID', + ] as const) { + if (!env[key]) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${key} is required when USE_MOCK_PTERODACTYL is false`, + path: [key], + }); + } + } + } + if (env.NODE_ENV === 'production' && (!env.DISCORD_CLIENT_ID || !env.DISCORD_CLIENT_SECRET)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Discord OAuth credentials are required in production', + path: ['DISCORD_CLIENT_ID'], + }); + } + if (env.NODE_ENV === 'production' && !env.OWNER_DISCORD_ID) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'OWNER_DISCORD_ID is required in production so the owner account is recoverable', + path: ['OWNER_DISCORD_ID'], + }); + } + }); + +export type Env = z.infer; + +export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { + const parsed = envSchema.safeParse(source); + if (!parsed.success) { + const details = parsed.error.issues + .map((issue) => ` - ${issue.path.join('.') || '(root)'}: ${issue.message}`) + .join('\n'); + throw new Error(`Invalid environment configuration:\n${details}`); + } + return parsed.data; +} + +/** True when the panel has enough configuration to talk to a game server backend. */ +export function isPterodactylConfigured(env: Env): boolean { + return ( + env.USE_MOCK_PTERODACTYL || + Boolean(env.PTERODACTYL_BASE_URL && env.PTERODACTYL_CLIENT_API_KEY && env.PTERODACTYL_SERVER_ID) + ); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..a2e3042 --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,163 @@ +import { createApp } from './app.js'; +import { createDb } from './db/client.js'; +import { isPterodactylConfigured, loadEnv } from './env.js'; +import { createLogger } from './lib/logger.js'; +import { SessionService } from './modules/auth/session-service.js'; +import { ConfigFileGateway } from './modules/config/config-file-gateway.js'; +import { ConfigSyncService } from './modules/config/config-sync.js'; +import { ServerModsService } from './modules/config/mods-service.js'; +import { PerformanceSettingsService } from './modules/config/performance-service.js'; +import { ResourceHistoryService } from './modules/servers/resource-history.js'; +import { MissionCatalog } from './modules/reforger-logs/missions-catalog.js'; +import { MockGameServerProvider } from './modules/pterodactyl/mock-provider.js'; +import { PterodactylProvider } from './modules/pterodactyl/pterodactyl-provider.js'; +import type { GameServerProvider } from './modules/pterodactyl/types.js'; +import { DrizzleIngestionStore } from './modules/reforger-logs/ingestion/drizzle-store.js'; +import { LogIngestionService } from './modules/reforger-logs/ingestion/ingestion-service.js'; +import { createLogPathResolver } from './modules/reforger-logs/ingestion/log-path-resolver.js'; +import { PterodactylLogSource } from './modules/reforger-logs/ingestion/pterodactyl-log-source.js'; +import { IngestionScheduler } from './modules/reforger-logs/ingestion/scheduler.js'; +import { ServerService } from './modules/servers/server-service.js'; +import { WorkshopClient } from './modules/workshop/workshop-client.js'; + +const logger = createLogger(); + +const env = loadEnv(); +const { db, pool } = createDb(env.DATABASE_URL); + +const mockLogPath = env.REFORGER_ADMIN_LOG_PATH || '/profile/logs/console.log'; +const provider: GameServerProvider = env.USE_MOCK_PTERODACTYL + ? new MockGameServerProvider({ logPath: mockLogPath }) + : new PterodactylProvider({ + baseUrl: env.PTERODACTYL_BASE_URL, + apiKey: env.PTERODACTYL_CLIENT_API_KEY, + }); + +const sessions = new SessionService(db, env.OWNER_DISCORD_ID); +const servers = new ServerService(db); +const workshop = new WorkshopClient({ baseUrl: env.REFORGER_WORKSHOP_API_BASE_URL }); +const configSync = isPterodactylConfigured(env) + ? new ConfigSyncService(provider, servers, logger, env.REFORGER_CONFIG_PATH) + : null; +const gateway = new ConfigFileGateway(provider, env.REFORGER_CONFIG_PATH); +const mods = configSync ? new ServerModsService(gateway, configSync, logger) : null; +const performance = configSync ? new PerformanceSettingsService(gateway, configSync, logger) : null; +const resourceHistory = new ResourceHistoryService(provider, logger); + +// Log ingestion runs when a backend is configured and we know where logs live. +const logsConfigured = + isPterodactylConfigured(env) && + Boolean(env.REFORGER_ADMIN_LOG_PATH || env.REFORGER_LOG_DIRECTORY || env.USE_MOCK_PTERODACTYL); + +const primaryServer = (await servers.listServers())[0] ?? null; +const providerServerId = primaryServer + ? (primaryServer.pterodactylServerId ?? primaryServer.slug) + : ''; + +const resolveLogPath = + logsConfigured && primaryServer + ? createLogPathResolver({ + provider, + providerServerId, + explicitPath: env.USE_MOCK_PTERODACTYL ? mockLogPath : env.REFORGER_ADMIN_LOG_PATH, + directory: env.REFORGER_LOG_DIRECTORY, + fileName: env.REFORGER_LOG_FILE_PATTERN, + }) + : null; + +const missions = + resolveLogPath && primaryServer + ? new MissionCatalog(provider, resolveLogPath, providerServerId) + : null; + +let scheduler: IngestionScheduler | null = null; +if (resolveLogPath) { + const ingestion = new LogIngestionService( + new PterodactylLogSource(provider), + new DrizzleIngestionStore(db), + logger, + { maxDownloadBytes: env.REFORGER_LOG_MAX_DOWNLOAD_BYTES }, + ); + scheduler = new IngestionScheduler( + ingestion, + logger, + env.REFORGER_LOG_POLL_INTERVAL_SECONDS * 1000, + ); +} else { + logger.info('log ingestion disabled (backend or log location not configured)'); +} + +const app = createApp({ + env, + logger, + db, + sessions, + servers, + provider, + workshop, + scheduler, + resolveLogPath, + configSync, + mods, + performance, + resourceHistory, + missions, +}); + +const httpServer = app.listen(env.PORT, () => { + logger.info( + { port: env.PORT, mockPterodactyl: env.USE_MOCK_PTERODACTYL }, + 'reforger-panel API listening', + ); +}); + +if (scheduler && resolveLogPath && primaryServer) { + scheduler.start([ + { + serverId: primaryServer.id, + providerServerId, + resolveLogPath, + }, + ]); +} + +if (primaryServer && isPterodactylConfigured(env)) { + resourceHistory.start([{ serverId: primaryServer.id, providerServerId }]); +} + +// Import the real config.json at startup and on an interval so the panel +// always reflects what the server actually runs. +let configSyncTimer: ReturnType | null = null; +if (configSync) { + void configSync.syncAllQuietly(); + configSyncTimer = setInterval( + () => void configSync.syncAllQuietly(), + env.REFORGER_CONFIG_SYNC_INTERVAL_SECONDS * 1000, + ); + configSyncTimer.unref(); +} + +// Hourly cleanup of expired sessions. +const sessionCleanup = setInterval( + () => void sessions.deleteExpiredSessions().catch(() => undefined), + 60 * 60 * 1000, +); +sessionCleanup.unref(); + +let shuttingDown = false; +async function shutdown(signal: string) { + if (shuttingDown) return; + shuttingDown = true; + logger.info({ signal }, 'shutting down'); + httpServer.close(); + clearInterval(sessionCleanup); + if (configSyncTimer) clearInterval(configSyncTimer); + resourceHistory.stop(); + if (scheduler) await scheduler.stop(); + if (provider instanceof MockGameServerProvider) provider.dispose(); + await pool.end(); + process.exit(0); +} + +process.on('SIGINT', () => void shutdown('SIGINT')); +process.on('SIGTERM', () => void shutdown('SIGTERM')); diff --git a/apps/api/src/lib/crypto.ts b/apps/api/src/lib/crypto.ts new file mode 100644 index 0000000..8f7af7e --- /dev/null +++ b/apps/api/src/lib/crypto.ts @@ -0,0 +1,31 @@ +import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; + +/** 256-bit URL-safe random token. */ +export function generateToken(): string { + return randomBytes(32).toString('base64url'); +} + +/** Sessions are stored by token hash so a DB leak does not leak usable cookies. */ +export function hashSessionToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +export function sha256Hex(input: string): string { + return createHash('sha256').update(input).digest('hex'); +} + +export function signValue(value: string, secret: string): string { + const signature = createHmac('sha256', secret).update(value).digest('base64url'); + return `${value}.${signature}`; +} + +export function verifySignedValue(signed: string, secret: string): string | null { + const separator = signed.lastIndexOf('.'); + if (separator <= 0) return null; + const value = signed.slice(0, separator); + const expected = signValue(value, secret); + const a = Buffer.from(signed); + const b = Buffer.from(expected); + if (a.length !== b.length || !timingSafeEqual(a, b)) return null; + return value; +} diff --git a/apps/api/src/lib/errors.ts b/apps/api/src/lib/errors.ts new file mode 100644 index 0000000..d63a8c0 --- /dev/null +++ b/apps/api/src/lib/errors.ts @@ -0,0 +1,47 @@ +import type { ApiErrorCode } from '@reforger-panel/shared'; + +const STATUS_BY_CODE: Record = { + UNAUTHENTICATED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + VALIDATION_ERROR: 400, + RATE_LIMITED: 429, + CONFLICT: 409, + UPSTREAM_UNAVAILABLE: 502, + NOT_CONFIGURED: 503, + INTERNAL_ERROR: 500, +}; + +export class ApiError extends Error { + readonly code: ApiErrorCode; + readonly status: number; + + constructor(code: ApiErrorCode, message: string) { + super(message); + this.name = 'ApiError'; + this.code = code; + this.status = STATUS_BY_CODE[code]; + } + + static unauthenticated(message = 'You must be signed in.') { + return new ApiError('UNAUTHENTICATED', message); + } + static forbidden(message = 'You do not have permission to perform this action.') { + return new ApiError('FORBIDDEN', message); + } + static notFound(message = 'Not found.') { + return new ApiError('NOT_FOUND', message); + } + static validation(message: string) { + return new ApiError('VALIDATION_ERROR', message); + } + static rateLimited(message = 'Too many requests. Try again shortly.') { + return new ApiError('RATE_LIMITED', message); + } + static upstream(message = 'An upstream service is unavailable.') { + return new ApiError('UPSTREAM_UNAVAILABLE', message); + } + static notConfigured(message = 'This feature is not configured.') { + return new ApiError('NOT_CONFIGURED', message); + } +} diff --git a/apps/api/src/lib/logger.ts b/apps/api/src/lib/logger.ts new file mode 100644 index 0000000..d86ac2a --- /dev/null +++ b/apps/api/src/lib/logger.ts @@ -0,0 +1,48 @@ +import { pino } from 'pino'; + +const REDACT_PATHS = [ + 'req.headers.authorization', + 'req.headers.cookie', + 'res.headers["set-cookie"]', + '*.apiKey', + '*.clientSecret', + '*.sessionToken', + '*.password', + 'apiKey', + 'clientSecret', + 'sessionToken', +]; + +export function createLogger(level?: string) { + return pino({ + level: level ?? (process.env.NODE_ENV === 'test' ? 'silent' : 'info'), + redact: { paths: REDACT_PATHS, censor: '[redacted]' }, + transport: + process.env.NODE_ENV === 'development' + ? { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss' } } + : undefined, + }); +} + +export type Logger = ReturnType; + +const SECRET_HINTS = [/api[_-]?key/i, /secret/i, /token/i, /password/i, /authorization/i]; + +/** + * Strip anything that looks like a secret, an internal URL, or a stack trace + * from an error before it is persisted or shown to a user. + */ +export function sanitizeErrorMessage(error: unknown, maxLength = 300): string { + let message = error instanceof Error ? error.message : String(error); + message = message.split('\n')[0] ?? ''; + // Drop credentials embedded in URLs and query strings. + message = message.replace(/\/\/[^/\s:]+:[^@/\s]+@/g, '//[redacted]@'); + message = message.replace(/([?&](?:key|token|secret|password)=)[^&\s]+/gi, '$1[redacted]'); + for (const hint of SECRET_HINTS) { + if (hint.test(message)) { + // A secret-ish word appears; keep only a generic description. + return 'Upstream request failed (details withheld — see server logs)'; + } + } + return message.slice(0, maxLength); +} diff --git a/apps/api/src/lib/rate-limit.ts b/apps/api/src/lib/rate-limit.ts new file mode 100644 index 0000000..49a19b3 --- /dev/null +++ b/apps/api/src/lib/rate-limit.ts @@ -0,0 +1,33 @@ +import type { NextFunction, Request, Response } from 'express'; +import { ApiError } from './errors.js'; + +type Bucket = { count: number; resetAt: number }; + +/** + * Small in-memory fixed-window rate limiter. Sufficient for a single-process + * private panel; swap for a shared store if the API is ever scaled out. + */ +export function rateLimit(options: { windowMs: number; max: number; keyPrefix: string }) { + const buckets = new Map(); + + return (req: Request, _res: Response, next: NextFunction) => { + const now = Date.now(); + const key = `${options.keyPrefix}:${req.ip ?? 'unknown'}`; + let bucket = buckets.get(key); + if (!bucket || bucket.resetAt <= now) { + bucket = { count: 0, resetAt: now + options.windowMs }; + buckets.set(key, bucket); + } + bucket.count += 1; + if (buckets.size > 10_000) { + for (const [k, b] of buckets) { + if (b.resetAt <= now) buckets.delete(k); + } + } + if (bucket.count > options.max) { + next(ApiError.rateLimited()); + return; + } + next(); + }; +} diff --git a/apps/api/src/modules/auth/auth-middleware.ts b/apps/api/src/modules/auth/auth-middleware.ts new file mode 100644 index 0000000..16aaa40 --- /dev/null +++ b/apps/api/src/modules/auth/auth-middleware.ts @@ -0,0 +1,89 @@ +import type { NextFunction, Request, Response } from 'express'; +import { parse as parseCookies } from 'cookie'; +import type { Capability } from '@reforger-panel/shared'; +import { roleHasCapability } from '@reforger-panel/shared'; +import { ApiError } from '../../lib/errors.js'; +import type { SessionUser } from './session-service.js'; + +export const SESSION_COOKIE = 'rp_session'; + +declare module 'express-serve-static-core' { + interface Request { + user?: SessionUser; + sessionToken?: string; + } +} + +export interface SessionLookup { + getUserBySessionToken(token: string): Promise; +} + +export function readSessionToken(req: Request): string | null { + const header = req.headers.cookie; + if (!header) return null; + const cookies = parseCookies(header); + return cookies[SESSION_COOKIE] ?? null; +} + +/** Resolves the session cookie into req.user (if valid); never rejects on its own. */ +export function sessionResolver(sessions: SessionLookup) { + return async (req: Request, _res: Response, next: NextFunction) => { + try { + const token = readSessionToken(req); + if (token) { + const user = await sessions.getUserBySessionToken(token); + if (user) { + req.user = user; + req.sessionToken = token; + } + } + next(); + } catch (error) { + next(error); + } + }; +} + +export function requireAuth(req: Request, _res: Response, next: NextFunction) { + if (!req.user) { + next(ApiError.unauthenticated()); + return; + } + next(); +} + +/** Backend-enforced capability check. Frontend role checks are UI convenience only. */ +export function requireCapability(capability: Capability, message?: string) { + return (req: Request, _res: Response, next: NextFunction) => { + if (!req.user) { + next(ApiError.unauthenticated()); + return; + } + if (!roleHasCapability(req.user.role, capability)) { + next(ApiError.forbidden(message)); + return; + } + next(); + }; +} + +/** + * CSRF protection for state-changing endpoints: the SPA sends a custom header + * (which browsers only allow same-origin / via CORS we control), and when the + * browser supplies an Origin header it must match an allowed origin. + */ +export function csrfProtection(allowedOrigins: string[]) { + const allowed = new Set(allowedOrigins.map((o) => o.replace(/\/$/, ''))); + return (req: Request, _res: Response, next: NextFunction) => { + const origin = req.headers.origin; + if (origin && !allowed.has(origin.replace(/\/$/, ''))) { + next(ApiError.forbidden('Cross-origin request rejected.')); + return; + } + if (req.headers['x-csrf-protection'] !== '1') { + next(ApiError.forbidden('Missing CSRF protection header.')); + return; + } + next(); + }; +} diff --git a/apps/api/src/modules/auth/auth-routes.ts b/apps/api/src/modules/auth/auth-routes.ts new file mode 100644 index 0000000..c01880b --- /dev/null +++ b/apps/api/src/modules/auth/auth-routes.ts @@ -0,0 +1,162 @@ +import { Router } from 'express'; +import { serialize as serializeCookie } from 'cookie'; +import type { CurrentUser } from '@reforger-panel/shared'; +import { ROLE_CAPABILITIES } from '@reforger-panel/shared'; +import type { Env } from '../../env.js'; +import { ApiError } from '../../lib/errors.js'; +import { generateToken, signValue, verifySignedValue } from '../../lib/crypto.js'; +import { rateLimit } from '../../lib/rate-limit.js'; +import { buildAuthorizeUrl, exchangeCodeForProfile } from './discord.js'; +import type { SessionService } from './session-service.js'; +import { SESSION_COOKIE, requireAuth } from './auth-middleware.js'; + +const STATE_COOKIE = 'rp_oauth_state'; + +function sessionCookie(token: string, expiresAt: Date, secure: boolean): string { + return serializeCookie(SESSION_COOKIE, token, { + httpOnly: true, + secure, + sameSite: 'lax', + path: '/', + expires: expiresAt, + }); +} + +function clearedSessionCookie(secure: boolean): string { + return serializeCookie(SESSION_COOKIE, '', { + httpOnly: true, + secure, + sameSite: 'lax', + path: '/', + maxAge: 0, + }); +} + +export function createAuthRouter(env: Env, sessions: SessionService): Router { + const router = Router(); + const secure = env.NODE_ENV === 'production'; + const oauthConfig = { + clientId: env.DISCORD_CLIENT_ID, + clientSecret: env.DISCORD_CLIENT_SECRET, + redirectUri: env.DISCORD_REDIRECT_URI, + }; + const authRateLimit = rateLimit({ windowMs: 60_000, max: 10, keyPrefix: 'auth' }); + + // Which login methods the frontend should offer. + router.get('/options', (_req, res) => { + res.json({ + discord: Boolean(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET), + devLogin: env.DEV_AUTH_BYPASS && env.NODE_ENV !== 'production', + }); + }); + + router.get('/discord', authRateLimit, (req, res, next) => { + if (!env.DISCORD_CLIENT_ID || !env.DISCORD_CLIENT_SECRET) { + next( + ApiError.notConfigured( + 'Discord OAuth is not configured. Set DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET.', + ), + ); + return; + } + const state = generateToken(); + res.setHeader( + 'Set-Cookie', + serializeCookie(STATE_COOKIE, signValue(state, env.SESSION_SECRET), { + httpOnly: true, + secure, + sameSite: 'lax', + path: '/api/auth', + maxAge: 10 * 60, + }), + ); + res.redirect(buildAuthorizeUrl(oauthConfig, state)); + }); + + router.get('/discord/callback', authRateLimit, async (req, res, next) => { + try { + const code = typeof req.query.code === 'string' ? req.query.code : null; + const state = typeof req.query.state === 'string' ? req.query.state : null; + const stateCookieRaw = req.headers.cookie + ?.split(';') + .map((c) => c.trim()) + .find((c) => c.startsWith(`${STATE_COOKIE}=`)) + ?.slice(STATE_COOKIE.length + 1); + + if (!code || !state || !stateCookieRaw) { + throw ApiError.validation('Missing OAuth code or state.'); + } + const expectedState = verifySignedValue( + decodeURIComponent(stateCookieRaw), + env.SESSION_SECRET, + ); + if (!expectedState || expectedState !== state) { + throw ApiError.forbidden('OAuth state mismatch. Please try signing in again.'); + } + + const profile = await exchangeCodeForProfile(oauthConfig, code); + const user = await sessions.upsertUserFromDiscord(profile); + const session = await sessions.createSession(user.id); + + res.setHeader('Set-Cookie', [ + sessionCookie(session.token, session.expiresAt, secure), + serializeCookie(STATE_COOKIE, '', { path: '/api/auth', maxAge: 0 }), + ]); + res.redirect(env.WEB_ORIGIN); + } catch (error) { + next(error); + } + }); + + // Local development helper: sign in without Discord. Refuses to exist in + // production (env validation also rejects the flag there). + if (env.DEV_AUTH_BYPASS && env.NODE_ENV !== 'production') { + router.post('/dev-login', authRateLimit, async (_req, res, next) => { + try { + const user = await sessions.upsertUserFromDiscord({ + discordId: env.OWNER_DISCORD_ID || '000000000000000000', + username: 'dev-owner', + displayName: 'Dev Owner', + avatarUrl: null, + }); + // The dev user is always the owner locally. + if (user.role !== 'owner') { + await sessions.setRole(user.id, 'owner'); + } + const session = await sessions.createSession(user.id); + res.setHeader('Set-Cookie', sessionCookie(session.token, session.expiresAt, secure)); + res.json({ ok: true }); + } catch (error) { + next(error); + } + }); + } + + router.get('/me', requireAuth, (req, res) => { + const user = req.user!; + const body: CurrentUser = { + id: user.id, + discordId: user.discordId, + username: user.username, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + role: user.role, + capabilities: [...ROLE_CAPABILITIES[user.role]], + }; + res.json(body); + }); + + router.post('/logout', async (req, res, next) => { + try { + if (req.sessionToken) { + await sessions.revokeSession(req.sessionToken); + } + res.setHeader('Set-Cookie', clearedSessionCookie(secure)); + res.json({ ok: true }); + } catch (error) { + next(error); + } + }); + + return router; +} diff --git a/apps/api/src/modules/auth/discord.ts b/apps/api/src/modules/auth/discord.ts new file mode 100644 index 0000000..2157b78 --- /dev/null +++ b/apps/api/src/modules/auth/discord.ts @@ -0,0 +1,82 @@ +import { z } from 'zod'; +import { ApiError } from '../../lib/errors.js'; + +const DISCORD_API = 'https://discord.com/api/v10'; +const DISCORD_OAUTH_AUTHORIZE = 'https://discord.com/oauth2/authorize'; + +const tokenResponseSchema = z.object({ + access_token: z.string(), + token_type: z.string(), +}); + +const discordUserSchema = z.object({ + id: z.string(), + username: z.string(), + global_name: z.string().nullable().optional(), + avatar: z.string().nullable().optional(), +}); + +export type DiscordProfile = { + discordId: string; + username: string; + displayName: string | null; + avatarUrl: string | null; +}; + +export type DiscordOAuthConfig = { + clientId: string; + clientSecret: string; + redirectUri: string; +}; + +export function buildAuthorizeUrl(config: DiscordOAuthConfig, state: string): string { + const url = new URL(DISCORD_OAUTH_AUTHORIZE); + url.searchParams.set('client_id', config.clientId); + url.searchParams.set('redirect_uri', config.redirectUri); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', 'identify'); + url.searchParams.set('state', state); + url.searchParams.set('prompt', 'none'); + return url.toString(); +} + +export async function exchangeCodeForProfile( + config: DiscordOAuthConfig, + code: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const tokenResponse = await fetchImpl(`${DISCORD_API}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + grant_type: 'authorization_code', + code, + redirect_uri: config.redirectUri, + }), + signal: AbortSignal.timeout(10_000), + }); + if (!tokenResponse.ok) { + throw ApiError.upstream('Discord token exchange failed.'); + } + const token = tokenResponseSchema.parse(await tokenResponse.json()); + + const userResponse = await fetchImpl(`${DISCORD_API}/users/@me`, { + headers: { Authorization: `${token.token_type} ${token.access_token}` }, + signal: AbortSignal.timeout(10_000), + }); + if (!userResponse.ok) { + throw ApiError.upstream('Failed to fetch Discord profile.'); + } + const user = discordUserSchema.parse(await userResponse.json()); + + return { + discordId: user.id, + username: user.username, + displayName: user.global_name ?? null, + avatarUrl: user.avatar + ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128` + : null, + }; +} diff --git a/apps/api/src/modules/auth/session-service.ts b/apps/api/src/modules/auth/session-service.ts new file mode 100644 index 0000000..0493997 --- /dev/null +++ b/apps/api/src/modules/auth/session-service.ts @@ -0,0 +1,121 @@ +import { eq, lt } from 'drizzle-orm'; +import type { Role } from '@reforger-panel/shared'; +import type { Db } from '../../db/client.js'; +import { schema } from '../../db/client.js'; +import { generateToken, hashSessionToken } from '../../lib/crypto.js'; +import type { DiscordProfile } from './discord.js'; + +export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days, revocable server-side + +export type SessionUser = { + id: string; + discordId: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + role: Role; +}; + +/** + * Role assignment at login: the configured owner Discord ID always gets (and + * keeps) `owner`; existing users keep their locally-assigned role; everyone + * new starts as `viewer`. + */ +export function resolveRoleForLogin( + existingRole: Role | null, + discordId: string, + ownerDiscordId: string, +): Role { + if (ownerDiscordId !== '' && discordId === ownerDiscordId) return 'owner'; + return existingRole ?? 'viewer'; +} + +export class SessionService { + constructor( + private readonly db: Db, + private readonly ownerDiscordId: string, + ) {} + + /** Create or update the local user record for a Discord login. */ + async upsertUserFromDiscord(profile: DiscordProfile): Promise { + const existing = await this.db + .select() + .from(schema.users) + .where(eq(schema.users.discordId, profile.discordId)); + + if (existing[0]) { + const nextRole = resolveRoleForLogin( + existing[0].role, + profile.discordId, + this.ownerDiscordId, + ); + const [updated] = await this.db + .update(schema.users) + .set({ + username: profile.username, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + role: nextRole, + }) + .where(eq(schema.users.id, existing[0].id)) + .returning(); + return updated!; + } + + const [created] = await this.db + .insert(schema.users) + .values({ + discordId: profile.discordId, + username: profile.username, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + role: resolveRoleForLogin(null, profile.discordId, this.ownerDiscordId), + }) + .returning(); + return created!; + } + + async setRole(userId: string, role: Role): Promise { + const [updated] = await this.db + .update(schema.users) + .set({ role }) + .where(eq(schema.users.id, userId)) + .returning(); + return updated ?? null; + } + + /** Returns the raw token for the cookie; only its hash is persisted. */ + async createSession(userId: string): Promise<{ token: string; expiresAt: Date }> { + const token = generateToken(); + const expiresAt = new Date(Date.now() + SESSION_TTL_MS); + await this.db.insert(schema.sessions).values({ + id: hashSessionToken(token), + userId, + expiresAt, + }); + return { token, expiresAt }; + } + + async getUserBySessionToken(token: string): Promise { + const rows = await this.db + .select({ session: schema.sessions, user: schema.users }) + .from(schema.sessions) + .innerJoin(schema.users, eq(schema.users.id, schema.sessions.userId)) + .where(eq(schema.sessions.id, hashSessionToken(token))); + const row = rows[0]; + if (!row) return null; + if (row.session.expiresAt.getTime() <= Date.now()) { + await this.db.delete(schema.sessions).where(eq(schema.sessions.id, row.session.id)); + return null; + } + return row.user; + } + + async revokeSession(token: string): Promise { + await this.db.delete(schema.sessions).where(eq(schema.sessions.id, hashSessionToken(token))); + } + + async deleteExpiredSessions(): Promise { + await this.db.delete(schema.sessions).where(lt(schema.sessions.expiresAt, new Date())); + } +} diff --git a/apps/api/src/modules/config/config-file-gateway.ts b/apps/api/src/modules/config/config-file-gateway.ts new file mode 100644 index 0000000..4729745 --- /dev/null +++ b/apps/api/src/modules/config/config-file-gateway.ts @@ -0,0 +1,66 @@ +import { ApiError } from '../../lib/errors.js'; +import type { GameServerProvider } from '../pterodactyl/types.js'; + +const CONFIG_MAX_BYTES = 256 * 1024; + +export function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** + * Shared read-modify-write access to the server's config.json: size-guarded + * download + parse, and a write path that backs the previous content up to + * `.bak` and verifies the upload by downloading it again. Callers + * mutate only their own keys on the parsed document so everything else in the + * file passes through untouched. + */ +export class ConfigFileGateway { + constructor( + private readonly provider: GameServerProvider, + readonly configPath: string, + ) {} + + async download( + providerServerId: string, + ): Promise<{ raw: string; root: Record }> { + const file = await this.provider.downloadTextFile( + providerServerId, + this.configPath, + CONFIG_MAX_BYTES, + ); + if (file.truncated) { + throw ApiError.upstream('Server config.json is unexpectedly large; refusing to modify it.'); + } + let parsed: unknown; + try { + parsed = JSON.parse(file.content.replace(/^\uFEFF/, '')); + } catch { + throw ApiError.upstream('Server config.json is not valid JSON.'); + } + const root = asRecord(parsed); + if (!root || !asRecord(root.game)) { + throw ApiError.upstream('Server config.json has no "game" section; refusing to modify it.'); + } + return { raw: file.content, root }; + } + + /** + * Backs up `previousRaw`, writes the mutated document, downloads it again + * and hands the verified parsed result to `verify` (throw there to fail). + */ + async write( + providerServerId: string, + root: Record, + previousRaw: string, + verify: (readBack: Record) => void, + ): Promise> { + await this.provider.writeTextFile(providerServerId, `${this.configPath}.bak`, previousRaw); + const serialized = `${JSON.stringify(root, null, 4)}\n`; + await this.provider.writeTextFile(providerServerId, this.configPath, serialized); + const readBack = await this.download(providerServerId); + verify(readBack.root); + return readBack.root; + } +} diff --git a/apps/api/src/modules/config/config-sync.ts b/apps/api/src/modules/config/config-sync.ts new file mode 100644 index 0000000..0b726c1 --- /dev/null +++ b/apps/api/src/modules/config/config-sync.ts @@ -0,0 +1,68 @@ +import type { ReforgerServerConfig } from '@reforger-panel/shared'; +import { sanitizeErrorMessage, type Logger } from '../../lib/logger.js'; +import type { GameServerProvider } from '../pterodactyl/types.js'; +import type { ServerRecord, ServerService } from '../servers/server-service.js'; +import { parseReforgerConfigJson } from './reforger-config-file.js'; + +const CONFIG_MAX_BYTES = 256 * 1024; + +export type ConfigSyncResult = { + serverName: string; + maxPlayers: number; + config: ReforgerServerConfig; +}; + +/** + * Reads the server's real config.json (via the provider, read-only) and keeps + * the server row's name/maxPlayers in line with what the server actually + * runs. Configuration is always served live; no revision history is kept. + */ +export class ConfigSyncService { + constructor( + private readonly provider: GameServerProvider, + private readonly servers: ServerService, + private readonly logger: Logger, + private readonly configPath: string, + ) {} + + async getLiveConfig(server: ServerRecord): Promise { + const providerServerId = server.pterodactylServerId ?? server.slug; + const file = await this.provider.downloadTextFile( + providerServerId, + this.configPath, + CONFIG_MAX_BYTES, + ); + return parseReforgerConfigJson(file.content); + } + + async sync(server: ServerRecord): Promise { + const config = await this.getLiveConfig(server); + const maxPlayers = config.maxPlayers > 0 ? config.maxPlayers : null; + if (server.name !== config.serverName || server.maxPlayers !== maxPlayers) { + await this.servers.updateServerInfo(server.id, { + name: config.serverName, + maxPlayers, + }); + this.logger.info( + { serverId: server.id, serverName: config.serverName, maxPlayers }, + 'server info updated from config.json', + ); + } + return { serverName: config.serverName, maxPlayers: config.maxPlayers, config }; + } + + /** Sync all servers, logging failures instead of throwing (for the poll loop). */ + async syncAllQuietly(): Promise { + const servers = await this.servers.listServers(); + for (const server of servers) { + try { + await this.sync(server); + } catch (error) { + this.logger.warn( + { serverId: server.id, error: sanitizeErrorMessage(error) }, + 'config sync failed', + ); + } + } + } +} diff --git a/apps/api/src/modules/config/mods-service.ts b/apps/api/src/modules/config/mods-service.ts new file mode 100644 index 0000000..ba1be39 --- /dev/null +++ b/apps/api/src/modules/config/mods-service.ts @@ -0,0 +1,96 @@ +import type { + ReforgerConfigMod, + ServerModsResponse, + UpdateModsResult, +} from '@reforger-panel/shared'; +import { ApiError } from '../../lib/errors.js'; +import type { Logger } from '../../lib/logger.js'; +import type { ServerRecord } from '../servers/server-service.js'; +import { asRecord, type ConfigFileGateway } from './config-file-gateway.js'; +import type { ConfigSyncService } from './config-sync.js'; + +function readMods(root: Record): ReforgerConfigMod[] { + const game = asRecord(root.game); + if (!game || !Array.isArray(game.mods)) return []; + return game.mods + .map((entry): ReforgerConfigMod | null => { + const mod = asRecord(entry); + const modId = typeof mod?.modId === 'string' ? mod.modId : ''; + if (!modId) return null; + const name = typeof mod?.name === 'string' && mod.name ? mod.name : undefined; + const version = typeof mod?.version === 'string' && mod.version ? mod.version : undefined; + return { + modId, + ...(name ? { name } : {}), + ...(version ? { version } : {}), + }; + }) + .filter((mod): mod is ReforgerConfigMod => mod !== null); +} + +/** + * Manages the `game.mods` array of the server's real config.json through the + * shared ConfigFileGateway (backup + read-back verification; all other config + * fields pass through untouched). Changes apply on the next server restart. + */ +export class ServerModsService { + constructor( + private readonly gateway: ConfigFileGateway, + private readonly configSync: ConfigSyncService, + private readonly logger: Logger, + ) {} + + private providerId(server: ServerRecord): string { + return server.pterodactylServerId ?? server.slug; + } + + async getMods(server: ServerRecord): Promise { + const { root } = await this.gateway.download(this.providerId(server)); + return { mods: readMods(root), fetchedAt: new Date().toISOString() }; + } + + async setMods(server: ServerRecord, mods: ReforgerConfigMod[]): Promise { + const providerId = this.providerId(server); + const { raw, root } = await this.gateway.download(providerId); + const previous = readMods(root); + + const previousIds = new Set(previous.map((mod) => mod.modId.toUpperCase())); + const nextIds = new Set(mods.map((mod) => mod.modId.toUpperCase())); + const added = [...nextIds].filter((id) => !previousIds.has(id)).length; + const removed = [...previousIds].filter((id) => !nextIds.has(id)).length; + + const game = asRecord(root.game)!; + game.mods = mods.map((mod) => ({ + modId: mod.modId.toUpperCase(), + ...(mod.name ? { name: mod.name } : {}), + ...(mod.version ? { version: mod.version } : {}), + })); + + const verified = await this.gateway.write(providerId, root, raw, (readBack) => { + const verifyIds = readMods(readBack) + .map((mod) => mod.modId.toUpperCase()) + .sort(); + if (JSON.stringify(verifyIds) !== JSON.stringify([...nextIds].sort())) { + throw ApiError.upstream( + 'Config write verification failed — the file on the server does not match. Check config.json.bak.', + ); + } + }); + + await this.configSync.sync(server).catch((error) => { + this.logger.warn( + { serverId: server.id, err: String(error) }, + 'post-write config sync failed', + ); + }); + + this.logger.info({ serverId: server.id, added, removed }, 'server mods updated'); + return { + mods: readMods(verified), + fetchedAt: new Date().toISOString(), + added, + removed, + requiresRestart: true, + }; + } +} diff --git a/apps/api/src/modules/config/performance-service.ts b/apps/api/src/modules/config/performance-service.ts new file mode 100644 index 0000000..cee9f28 --- /dev/null +++ b/apps/api/src/modules/config/performance-service.ts @@ -0,0 +1,145 @@ +import type { + PerformanceSettings, + PerformanceSettingsPatch, + PerformanceSettingsResponse, +} from '@reforger-panel/shared'; +import type { Logger } from '../../lib/logger.js'; +import type { ServerRecord } from '../servers/server-service.js'; +import { asRecord, type ConfigFileGateway } from './config-file-gateway.js'; +import type { ConfigSyncService } from './config-sync.js'; + +/** Where each performance field lives inside config.json. */ +const FIELD_LOCATIONS: Record< + keyof PerformanceSettings, + ['game' | 'gameProperties' | 'operating', string] +> = { + scenarioId: ['game', 'scenarioId'], + maxPlayers: ['game', 'maxPlayers'], + serverMaxViewDistance: ['gameProperties', 'serverMaxViewDistance'], + networkViewDistance: ['gameProperties', 'networkViewDistance'], + serverMinGrassDistance: ['gameProperties', 'serverMinGrassDistance'], + disableThirdPerson: ['gameProperties', 'disableThirdPerson'], + fastValidation: ['gameProperties', 'fastValidation'], + battlEye: ['gameProperties', 'battlEye'], + aiLimit: ['operating', 'aiLimit'], + playerSaveTime: ['operating', 'playerSaveTime'], + slotReservationTimeout: ['operating', 'slotReservationTimeout'], + lobbyPlayerSynchronise: ['operating', 'lobbyPlayerSynchronise'], +}; + +function sectionFor( + root: Record, + section: 'game' | 'gameProperties' | 'operating', + createMissing: boolean, +): Record | null { + const game = asRecord(root.game)!; + if (section === 'game') return game; + if (section === 'gameProperties') { + let props = asRecord(game.gameProperties); + if (!props && createMissing) { + props = {}; + game.gameProperties = props; + } + return props; + } + let operating = asRecord(root.operating); + if (!operating && createMissing) { + operating = {}; + root.operating = operating; + } + return operating; +} + +export function readPerformanceSettings(root: Record): PerformanceSettings { + const result = {} as Record; + for (const [field, [section, key]] of Object.entries(FIELD_LOCATIONS) as [ + keyof PerformanceSettings, + ['game' | 'gameProperties' | 'operating', string], + ][]) { + const container = sectionFor(root, section, false); + const value = container?.[key]; + const validType = + field === 'scenarioId' + ? typeof value === 'string' + : typeof value === 'number' || typeof value === 'boolean'; + result[field] = validType ? (value as number | boolean | string) : null; + } + return result as PerformanceSettings; +} + +/** + * Edits the performance-related keys of the live config.json. A `null` value + * removes the key from the file entirely so the game's own default applies — + * network/identity fields (bind address, ports, passwords, rcon…) are never + * touched by this service. + */ +export class PerformanceSettingsService { + constructor( + private readonly gateway: ConfigFileGateway, + private readonly configSync: ConfigSyncService, + private readonly logger: Logger, + ) {} + + private providerId(server: ServerRecord): string { + return server.pterodactylServerId ?? server.slug; + } + + async get(server: ServerRecord): Promise { + const { root } = await this.gateway.download(this.providerId(server)); + return { settings: readPerformanceSettings(root), fetchedAt: new Date().toISOString() }; + } + + async update( + server: ServerRecord, + patch: PerformanceSettingsPatch, + ): Promise { + const providerId = this.providerId(server); + const { raw, root } = await this.gateway.download(providerId); + const before = readPerformanceSettings(root); + + const changedFields: string[] = []; + for (const [field, [section, key]] of Object.entries(FIELD_LOCATIONS) as [ + keyof PerformanceSettings, + ['game' | 'gameProperties' | 'operating', string], + ][]) { + if (!(field in patch)) continue; // untouched fields stay as-is + const next = patch[field] as number | boolean | string | null; + if (before[field] === next) continue; + changedFields.push(field); + if (next === null) { + const container = sectionFor(root, section, false); + if (container) delete container[key]; + } else { + const container = sectionFor(root, section, true)!; + container[key] = next; + } + } + + if (changedFields.length > 0) { + await this.gateway.write(providerId, root, raw, (readBack) => { + const after = readPerformanceSettings(readBack); + for (const field of changedFields) { + if ( + after[field as keyof PerformanceSettings] !== patch[field as keyof PerformanceSettings] + ) { + throw new Error('Config write verification failed. Check config.json.bak.'); + } + } + }); + await this.configSync.sync(server).catch((error) => { + this.logger.warn( + { serverId: server.id, err: String(error) }, + 'post-write config sync failed', + ); + }); + this.logger.info({ serverId: server.id, changedFields }, 'performance settings updated'); + } + + return { + settings: { ...before, ...patch } as PerformanceSettings, + fetchedAt: new Date().toISOString(), + changedFields, + requiresRestart: true, + }; + } +} diff --git a/apps/api/src/modules/config/reforger-config-file.test.ts b/apps/api/src/modules/config/reforger-config-file.test.ts new file mode 100644 index 0000000..0661f95 --- /dev/null +++ b/apps/api/src/modules/config/reforger-config-file.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { parseReforgerConfigJson } from './reforger-config-file.js'; +import { ApiError } from '../../lib/errors.js'; + +// Shape from the Reforger dedicated-server docs / typical Pterodactyl egg output. +const REAL_SHAPE = { + bindAddress: '0.0.0.0', + bindPort: 2001, + publicAddress: '', + publicPort: 2001, + a2s: { address: '0.0.0.0', port: 17777 }, + rcon: { address: '127.0.0.1', port: 19999, password: 'hunter2', permission: 'admin' }, + game: { + name: 'DazzledCorp Training Grounds', + password: '', + passwordAdmin: 'secret', + admins: ['76561198000000000'], + scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf', + maxPlayers: 16, + visible: true, + crossPlatform: true, + supportedPlatforms: ['PLATFORM_PC', 'PLATFORM_XBL'], + gameProperties: { + serverMaxViewDistance: 2500, + serverMinGrassDistance: 50, + networkViewDistance: 1000, + disableThirdPerson: true, + fastValidation: true, + battlEye: true, + VONDisableUI: false, + }, + mods: [ + { modId: '591AF5BDA9F7CE8B', name: 'Some Mod', version: '1.0.2' }, + { modId: '5AAF0CCE3F001FB5' }, + ], + }, + operating: { lobbyPlayerSynchronise: true, aiLimit: -1, playerSaveTime: 120 }, +}; + +describe('parseReforgerConfigJson', () => { + it('maps a real-shaped config.json into the panel model', () => { + const config = parseReforgerConfigJson(JSON.stringify(REAL_SHAPE)); + expect(config).toEqual({ + serverName: 'DazzledCorp Training Grounds', + maxPlayers: 16, + scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf', + aiLimit: -1, + serverMaxViewDistance: 2500, + networkViewDistance: 1000, + crossPlatform: true, + disableThirdPerson: true, + mods: [ + { modId: '591AF5BDA9F7CE8B', name: 'Some Mod', version: '1.0.2' }, + { modId: '5AAF0CCE3F001FB5' }, + ], + }); + }); + + it('never includes credentials from the config file in the mapped model', () => { + const json = JSON.stringify(parseReforgerConfigJson(JSON.stringify(REAL_SHAPE))); + expect(json).not.toContain('hunter2'); + expect(json).not.toContain('secret'); + }); + + it('tolerates missing sections with neutral defaults', () => { + const config = parseReforgerConfigJson('{"game":{"name":"Bare"}}'); + expect(config.serverName).toBe('Bare'); + expect(config.maxPlayers).toBe(0); + expect(config.aiLimit).toBe(-1); + expect(config.mods).toEqual([]); + }); + + it('rejects invalid JSON with a sanitized upstream error', () => { + expect(() => parseReforgerConfigJson('not json {')).toThrow(ApiError); + }); +}); diff --git a/apps/api/src/modules/config/reforger-config-file.ts b/apps/api/src/modules/config/reforger-config-file.ts new file mode 100644 index 0000000..d0d7365 --- /dev/null +++ b/apps/api/src/modules/config/reforger-config-file.ts @@ -0,0 +1,74 @@ +import type { ReforgerServerConfig } from '@reforger-panel/shared'; +import { ApiError } from '../../lib/errors.js'; + +/** + * Maps a real Reforger server `config.json` (the file the dedicated server + * runs with, documented at + * https://community.bistudio.com/wiki/Arma_Reforger:Server_Config) into the + * panel's internal config model. Mapping is defensive: missing or oddly-typed + * fields fall back to neutral defaults instead of failing the sync. + */ + +function record(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function str(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback; +} + +function num(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function bool(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback; +} + +export function mapReforgerConfig(raw: unknown): ReforgerServerConfig { + const root = record(raw); + const game = record(root.game); + const gameProperties = record(game.gameProperties); + const operating = record(root.operating); + + const mods = Array.isArray(game.mods) + ? game.mods + .map((entry) => { + const mod = record(entry); + const modId = str(mod.modId); + if (!modId) return null; + return { + modId, + name: str(mod.name) || undefined, + version: str(mod.version) || undefined, + }; + }) + .filter((mod): mod is NonNullable => mod !== null) + : []; + + return { + serverName: str(game.name, 'Unnamed server'), + maxPlayers: num(game.maxPlayers, 0), + scenarioId: str(game.scenarioId), + // -1 means "no limit" in Reforger's operating.aiLimit. + aiLimit: num(operating.aiLimit, -1), + serverMaxViewDistance: num(gameProperties.serverMaxViewDistance, 0), + networkViewDistance: num(gameProperties.networkViewDistance, 0), + crossPlatform: bool(game.crossPlatform, false), + disableThirdPerson: bool(gameProperties.disableThirdPerson, false), + mods, + }; +} + +export function parseReforgerConfigJson(content: string): ReforgerServerConfig { + const text = content.replace(/^\uFEFF/, '').trim(); + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + throw ApiError.upstream('Server config.json is not valid JSON.'); + } + return mapReforgerConfig(raw); +} diff --git a/apps/api/src/modules/invites/invite-routes.ts b/apps/api/src/modules/invites/invite-routes.ts new file mode 100644 index 0000000..aa752e1 --- /dev/null +++ b/apps/api/src/modules/invites/invite-routes.ts @@ -0,0 +1,152 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { and, desc, eq, gt, isNull } from 'drizzle-orm'; +import { randomBytes } from 'node:crypto'; +import type { InviteSummary, Role } from '@reforger-panel/shared'; +import { ROLES } from '@reforger-panel/shared'; +import type { Db } from '../../db/client.js'; +import { schema } from '../../db/client.js'; +import { ApiError } from '../../lib/errors.js'; +import { rateLimit } from '../../lib/rate-limit.js'; +import { requireAuth, requireCapability } from '../auth/auth-middleware.js'; + +const createBodySchema = z.object({ + // Owner invites are deliberately not creatable; there is one owner. + role: z.enum(['server_admin', 'mission_lead', 'viewer']), + expiresInHours: z.number().int().min(1).max(8760).nullable().default(168), +}); + +const NEVER_EXPIRES_HOURS = 24 * 365 * 100; + +const redeemBodySchema = z.object({ + code: z.string().trim().min(4).max(64), +}); + +function inviteCode(): string { + // Readable, unambiguous, ~50 bits. + return randomBytes(10).toString('base64url').replace(/[-_]/g, 'x').slice(0, 12).toUpperCase(); +} + +export function createInviteRouter(db: Db): Router { + const router = Router(); + const redeemRateLimit = rateLimit({ windowMs: 60_000, max: 10, keyPrefix: 'invite-redeem' }); + + router.use(requireAuth); + + /** + * Redeem an invite: upgrades the calling user to the invite's role and + * consumes the code. Available to any signed-in user (rate limited). + */ + router.post('/redeem', redeemRateLimit, async (req, res, next) => { + try { + const body = redeemBodySchema.safeParse(req.body); + if (!body.success) throw ApiError.validation('Invalid invite code.'); + const user = req.user!; + + const rows = await db + .select() + .from(schema.invites) + .where( + and( + eq(schema.invites.code, body.data.code.toUpperCase()), + isNull(schema.invites.usedAt), + gt(schema.invites.expiresAt, new Date()), + ), + ); + const invite = rows[0]; + if (!invite) { + throw ApiError.notFound('This invite code is invalid, used, or expired.'); + } + if (user.role === 'owner') { + // Owners never downgrade themselves by redeeming a code. + res.json({ ok: true, role: user.role, changed: false }); + return; + } + + await db + .update(schema.invites) + .set({ usedByUserId: user.id, usedAt: new Date() }) + .where(eq(schema.invites.id, invite.id)); + await db + .update(schema.users) + .set({ role: invite.role as Role }) + .where(eq(schema.users.id, user.id)); + res.json({ ok: true, role: invite.role, changed: invite.role !== user.role }); + } catch (error) { + next(error); + } + }); + + router.use(requireCapability('users.manage', 'Only the owner can manage invites.')); + + router.get('/', async (_req, res, next) => { + try { + const rows = await db + .select({ invite: schema.invites, createdBy: schema.users }) + .from(schema.invites) + .leftJoin(schema.users, eq(schema.users.id, schema.invites.createdByUserId)) + .orderBy(desc(schema.invites.createdAt)) + .limit(50); + + const usedByIds = rows + .map((r) => r.invite.usedByUserId) + .filter((id): id is string => id !== null); + const usedByUsers = usedByIds.length > 0 ? await db.select().from(schema.users) : []; + const usedByName = new Map(usedByUsers.map((u) => [u.id, u.displayName ?? u.username])); + + const invites: InviteSummary[] = rows.map(({ invite, createdBy }) => ({ + id: invite.id, + code: invite.code, + role: invite.role, + createdBy: createdBy ? (createdBy.displayName ?? createdBy.username) : null, + expiresAt: invite.expiresAt?.toISOString() ?? null, + usedBy: invite.usedByUserId ? (usedByName.get(invite.usedByUserId) ?? 'unknown') : null, + usedAt: invite.usedAt?.toISOString() ?? null, + createdAt: invite.createdAt.toISOString(), + })); + res.json({ invites }); + } catch (error) { + next(error); + } + }); + + router.post('/', async (req, res, next) => { + try { + const body = createBodySchema.safeParse(req.body); + if (!body.success) throw ApiError.validation('Invalid invite request.'); + if (!ROLES.includes(body.data.role)) throw ApiError.validation('Invalid role.'); + const [invite] = await db + .insert(schema.invites) + .values({ + code: inviteCode(), + role: body.data.role, + createdByUserId: req.user!.id, + expiresAt: new Date( + Date.now() + (body.data.expiresInHours ?? NEVER_EXPIRES_HOURS) * 60 * 60 * 1000, + ), + }) + .returning(); + res.json({ + id: invite!.id, + code: invite!.code, + role: invite!.role, + expiresAt: invite!.expiresAt?.toISOString() ?? null, + }); + } catch (error) { + next(error); + } + }); + + router.delete('/:id', async (req, res, next) => { + try { + const id = z.string().uuid().safeParse(req.params.id); + if (!id.success) throw ApiError.validation('Invalid invite id.'); + await db.delete(schema.invites).where(eq(schema.invites.id, id.data)); + res.json({ ok: true }); + } catch (error) { + next(error); + } + }); + + return router; +} diff --git a/apps/api/src/modules/pterodactyl/mock-provider.ts b/apps/api/src/modules/pterodactyl/mock-provider.ts new file mode 100644 index 0000000..47a3b3a --- /dev/null +++ b/apps/api/src/modules/pterodactyl/mock-provider.ts @@ -0,0 +1,342 @@ +import type { + RestartScheduleInput, + ServerScheduleSummary, + ServerStatus, +} from '@reforger-panel/shared'; +import { ApiError } from '../../lib/errors.js'; +import type { + DownloadableFile, + GameServerProvider, + ProviderServerResources, + ServerFileEntry, +} from './types.js'; + +const START_DELAY_MS = 4_000; +const STOP_DELAY_MS = 2_500; + +function pad(n: number, width = 2): string { + return String(n).padStart(width, '0'); +} + +function timeOfDay(date: Date): string { + return `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.${pad( + date.getUTCMilliseconds(), + 3, + )}`; +} + +function dateStamp(date: Date): string { + return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`; +} + +/** + * Builds a plausible Reforger console.log covering the last ~50 minutes: + * server start, four connects, one disconnect. Line shapes mirror the real + * Enfusion/BattlEye output the parser targets (see parser/patterns.ts). + */ +export function buildMockConsoleLog(now: Date = new Date()): string { + const at = (minutesAgo: number, driftSeconds = 0) => + new Date(now.getTime() - minutesAgo * 60_000 + driftSeconds * 1000); + + const started = at(50); + const lines = [ + `------------------------------------------------------------------------------------------------`, + `Log started ${dateStamp(started)} ${timeOfDay(started).slice(0, 8)}`, + `${timeOfDay(started)} ENGINE : Enfusion engine build: 1.3.0.42 (mock)`, + `${timeOfDay(at(50, 4))} DEFAULT : Loading world.`, + `${timeOfDay(at(49))} DEFAULT : Game successfully created.`, + `${timeOfDay(at(48))} NETWORK : Server is ready to accept connections`, + `${timeOfDay(at(44))} DEFAULT : BattlEye Server: 'Player #1 Braeden (10.66.4.21:50241) connected'`, + `${timeOfDay(at(44, 2))} DEFAULT : BattlEye Server: 'Player #1 Braeden - GUID: 9f2ab04c11d9e0aa'`, + `${timeOfDay(at(38))} DEFAULT : BattlEye Server: 'Player #2 Sable (10.66.4.30:61022) connected'`, + `${timeOfDay(at(38, 1))} DEFAULT : BattlEye Server: 'Player #2 Sable - GUID: 41c7de9a5b02f311'`, + `${timeOfDay(at(31))} DEFAULT : BattlEye Server: 'Player #3 Kestrel (10.66.4.87:49155) connected'`, + `${timeOfDay(at(27))} SCRIPT : SCR_BaseGameMode: match state changed`, + `${timeOfDay(at(22))} DEFAULT : BattlEye Server: 'Player #4 Moss (10.66.4.44:51811) connected'`, + `${timeOfDay(at(22, 1))} DEFAULT : BattlEye Server: 'Player #4 Moss - GUID: c31009e2ab77d514'`, + `${timeOfDay(at(9))} DEFAULT : BattlEye Server: 'Player #3 Kestrel disconnected'`, + `${timeOfDay(at(2))} NETWORK : ### Connection stats`, + '', + ]; + return lines.join('\n'); +} + +/** + * In-process stand-in for Pterodactyl so the whole panel runs without + * credentials. Power actions transition through starting/stopping states, and + * the mock file system serves a generated console.log fixture. + */ +export class MockGameServerProvider implements GameServerProvider { + private status: ServerStatus = 'online'; + private startedAt = Date.now() - 50 * 60_000; + private transitionTimer: ReturnType | null = null; + private readonly logContent: string; + private readonly logPath: string; + + private readonly configPath: string; + private configContent: string; + private nextScheduleId = 2; + private schedules: ServerScheduleSummary[] = [ + { + id: '1', + name: 'Daily restart', + isActive: true, + onlyWhenOnline: true, + minute: '0', + hour: '9', + dayOfMonth: '*', + month: '*', + dayOfWeek: '*', + nextRunAt: null, + lastRunAt: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + tasks: [ + { + id: '1', + action: 'power', + payload: 'restart', + timeOffsetSeconds: 0, + continueOnFailure: false, + }, + ], + }, + ]; + /** Files written via writeTextFile (e.g. config.json backups). */ + readonly writtenFiles = new Map(); + + constructor(options: { logPath?: string; configPath?: string; now?: Date } = {}) { + this.logPath = options.logPath ?? '/profile/logs/console.log'; + this.logContent = buildMockConsoleLog(options.now ?? new Date()); + this.configPath = options.configPath ?? '/config.json'; + // Shape mirrors a real Reforger dedicated-server config.json. + this.configContent = JSON.stringify( + { + bindAddress: '0.0.0.0', + bindPort: 2001, + game: { + name: 'Mock Reforger Server', + scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf', + maxPlayers: 16, + crossPlatform: true, + gameProperties: { + serverMaxViewDistance: 2500, + networkViewDistance: 1500, + disableThirdPerson: false, + }, + mods: [{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' }], + }, + operating: { aiLimit: 40 }, + }, + null, + 2, + ); + } + + dispose() { + if (this.transitionTimer) clearTimeout(this.transitionTimer); + } + + private transition(to: ServerStatus, after: number, thenTo: ServerStatus) { + this.status = to; + if (this.transitionTimer) clearTimeout(this.transitionTimer); + this.transitionTimer = setTimeout(() => { + this.status = thenTo; + if (thenTo === 'online') this.startedAt = Date.now(); + this.transitionTimer = null; + }, after); + this.transitionTimer.unref?.(); + } + + async getServerStatus(): Promise { + return this.status; + } + + async getServerResources(): Promise { + const online = this.status === 'online'; + const wobble = (base: number, spread: number) => base + (Math.random() - 0.5) * spread; + return { + status: this.status, + cpuPercent: online ? Math.max(2, wobble(38, 14)) : 0, + cpuLimitPercent: 400, + memoryBytes: online ? Math.round(wobble(5.1, 0.6) * 1024 ** 3) : 0, + memoryLimitBytes: 8 * 1024 ** 3, + diskBytes: Math.round(22.4 * 1024 ** 3), + diskLimitBytes: 40 * 1024 ** 3, + networkRxBytes: online ? Math.round(wobble(9.2, 1.5) * 1024 ** 2) : 0, + networkTxBytes: online ? Math.round(wobble(26.8, 4) * 1024 ** 2) : 0, + uptimeMs: online ? Date.now() - this.startedAt : 0, + }; + } + + async startServer(): Promise { + if (this.status === 'online') return; + this.transition('starting', START_DELAY_MS, 'online'); + } + + async stopServer(): Promise { + if (this.status === 'offline') return; + this.transition('stopping', STOP_DELAY_MS, 'offline'); + } + + async restartServer(): Promise { + this.transition('stopping', STOP_DELAY_MS, 'starting'); + setTimeout(() => { + if (this.status === 'starting') { + this.status = 'online'; + this.startedAt = Date.now(); + } + }, STOP_DELAY_MS + START_DELAY_MS).unref?.(); + } + + async listFiles(_serverId: string, directory: string): Promise { + const dir = directory.replace(/\/$/, '') || '/'; + const logDir = this.logPath.slice(0, this.logPath.lastIndexOf('/')) || '/'; + if (dir !== logDir) return []; + return [ + { + name: this.logPath.slice(this.logPath.lastIndexOf('/') + 1), + isFile: true, + sizeBytes: Buffer.byteLength(this.logContent), + modifiedAt: new Date(), + }, + ]; + } + + async getFileDownloadUrl(): Promise { + throw ApiError.notConfigured('Direct downloads are not available in mock mode.'); + } + + async writeTextFile(_serverId: string, path: string, content: string): Promise { + this.writtenFiles.set(path, content); + if (path === this.configPath) { + this.configContent = content; + } + } + + private startupVariables = [ + { + name: 'Server Password', + description: 'Password required to join the server.', + envVariable: 'SERVER_PASSWORD', + serverValue: '', + defaultValue: '', + isEditable: true, + }, + { + name: 'Admin Password', + description: 'Password for in-game admin access.', + envVariable: 'ADMIN_PASSWORD', + serverValue: 'mock-admin-pass', + defaultValue: '', + isEditable: true, + }, + { + name: 'App ID', + description: 'Steam application id (managed by the egg).', + envVariable: 'SRCDS_APPID', + serverValue: '1874900', + defaultValue: '1874900', + isEditable: false, + }, + ]; + + async listStartupVariables() { + return this.startupVariables.map((v) => ({ ...v })); + } + + async updateStartupVariable(_serverId: string, envVariable: string, value: string) { + const variable = this.startupVariables.find((v) => v.envVariable === envVariable); + if (!variable || !variable.isEditable) { + throw ApiError.validation('This startup variable cannot be edited.'); + } + variable.serverValue = value; + } + + async listSchedules(): Promise { + return this.schedules.map((schedule) => ({ + ...schedule, + tasks: schedule.tasks.map((task) => ({ ...task })), + })); + } + + async createRestartSchedule( + _serverId: string, + input: RestartScheduleInput, + ): Promise { + const now = new Date().toISOString(); + const schedule: ServerScheduleSummary = { + id: String(this.nextScheduleId++), + name: input.name, + isActive: input.isActive, + onlyWhenOnline: input.onlyWhenOnline, + minute: String(input.minute), + hour: String(input.hour), + dayOfMonth: '*', + month: '*', + dayOfWeek: input.dayOfWeek, + nextRunAt: null, + lastRunAt: null, + createdAt: now, + updatedAt: now, + tasks: [ + { + id: String(this.nextScheduleId++), + action: 'power', + payload: 'restart', + timeOffsetSeconds: 0, + continueOnFailure: false, + }, + ], + }; + this.schedules.unshift(schedule); + return { ...schedule, tasks: schedule.tasks.map((task) => ({ ...task })) }; + } + + async updateRestartSchedule( + _serverId: string, + scheduleId: string, + input: RestartScheduleInput, + ): Promise { + const schedule = this.schedules.find((s) => s.id === scheduleId); + if (!schedule) throw ApiError.notFound('Schedule not found.'); + schedule.name = input.name; + schedule.isActive = input.isActive; + schedule.onlyWhenOnline = input.onlyWhenOnline; + schedule.minute = String(input.minute); + schedule.hour = String(input.hour); + schedule.dayOfWeek = input.dayOfWeek; + schedule.updatedAt = new Date().toISOString(); + return { ...schedule, tasks: schedule.tasks.map((task) => ({ ...task })) }; + } + + async deleteSchedule(_serverId: string, scheduleId: string): Promise { + this.schedules = this.schedules.filter((schedule) => schedule.id !== scheduleId); + } + + async downloadTextFile( + _serverId: string, + path: string, + maxBytes = 2 * 1024 * 1024, + ): Promise { + const content = + path === this.logPath + ? this.logContent + : path === this.configPath + ? this.configContent + : null; + if (content === null) { + throw ApiError.notFound(`Mock file not found: ${path}`); + } + const buffer = Buffer.from(content, 'utf8'); + const trimmed = + buffer.byteLength > maxBytes ? buffer.subarray(buffer.byteLength - maxBytes) : buffer; + return { + path, + content: trimmed.toString('utf8'), + totalSizeBytes: buffer.byteLength, + contentStartOffset: buffer.byteLength - trimmed.byteLength, + truncated: trimmed.byteLength < buffer.byteLength, + }; + } +} diff --git a/apps/api/src/modules/pterodactyl/pterodactyl-provider.ts b/apps/api/src/modules/pterodactyl/pterodactyl-provider.ts new file mode 100644 index 0000000..0a27a9a --- /dev/null +++ b/apps/api/src/modules/pterodactyl/pterodactyl-provider.ts @@ -0,0 +1,483 @@ +import type { + RestartScheduleInput, + ServerScheduleSummary, + ServerScheduleTask, + ServerStatus, +} from '@reforger-panel/shared'; +import { ApiError } from '../../lib/errors.js'; +import type { + DownloadableFile, + GameServerProvider, + ProviderServerResources, + ServerFileEntry, +} from './types.js'; + +const DEFAULT_TIMEOUT_MS = 10_000; +const DOWNLOAD_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024; + +type PterodactylOptions = { + baseUrl: string; + apiKey: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; +}; + +type PterodactylScheduleResponse = { + data?: { + attributes?: PterodactylScheduleAttributes; + }; +}; + +type PterodactylScheduleAttributes = { + id?: number | string; + name?: string; + cron?: { + minute?: string; + hour?: string; + day_of_month?: string; + month?: string; + day_of_week?: string; + }; + is_active?: boolean; + only_when_online?: boolean; + last_run_at?: string | null; + next_run_at?: string | null; + created_at?: string | null; + updated_at?: string | null; + relationships?: { + tasks?: { + data?: { + attributes?: { + id?: number | string; + action?: string; + payload?: string; + time_offset?: number; + continue_on_failure?: boolean; + }; + }[]; + }; + }; +}; + +function mapState(state: string): ServerStatus { + switch (state) { + case 'running': + return 'online'; + case 'offline': + return 'offline'; + case 'starting': + return 'starting'; + case 'stopping': + return 'stopping'; + default: + return 'unknown'; + } +} + +/** + * Pterodactyl Client API provider. Uses only client-scoped endpoints (status, + * resources, power, read-only file access). Errors are sanitized: they carry + * the endpoint category and HTTP status, never the API key or full URL. + */ +export class PterodactylProvider implements GameServerProvider { + private readonly baseUrl: string; + private readonly apiKey: string; + private readonly fetchImpl: typeof fetch; + private readonly timeoutMs: number; + private limitsCache = new Map< + string, + { + cpuLimitPercent: number | null; + memoryLimitBytes: number | null; + diskLimitBytes: number | null; + fetchedAt: number; + } + >(); + + constructor(options: PterodactylOptions) { + this.baseUrl = options.baseUrl.replace(/\/$/, ''); + this.apiKey = options.apiKey; + this.fetchImpl = options.fetchImpl ?? fetch; + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + } + + private async request( + label: string, + path: string, + init: { method?: string; body?: unknown; timeoutMs?: number; raw?: boolean } = {}, + ): Promise { + const url = `${this.baseUrl}/api/client${path}`; + let response: Response; + try { + response = await this.fetchImpl(url, { + method: init.method ?? 'GET', + headers: { + Authorization: `Bearer ${this.apiKey}`, + Accept: 'application/json', + ...(init.body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + body: init.body !== undefined ? JSON.stringify(init.body) : undefined, + signal: AbortSignal.timeout(init.timeoutMs ?? this.timeoutMs), + }); + } catch (error) { + const reason = + error instanceof Error && error.name === 'TimeoutError' ? 'timed out' : 'failed'; + throw ApiError.upstream(`Pterodactyl request (${label}) ${reason}.`); + } + if (!response.ok) { + throw ApiError.upstream(`Pterodactyl request (${label}) returned HTTP ${response.status}.`); + } + if (init.raw) { + return (await response.text()) as T; + } + if (response.status === 204) { + return undefined as T; + } + const text = await response.text(); + if (!text) return undefined as T; + try { + return JSON.parse(text) as T; + } catch { + throw ApiError.upstream(`Pterodactyl request (${label}) returned invalid JSON.`); + } + } + + private async getLimits(serverId: string) { + const cached = this.limitsCache.get(serverId); + if (cached && Date.now() - cached.fetchedAt < 5 * 60_000) return cached; + const data = await this.request<{ + attributes?: { limits?: { cpu?: number; memory?: number; disk?: number } }; + }>('server details', `/servers/${encodeURIComponent(serverId)}`); + const limits = data.attributes?.limits; + const entry = { + cpuLimitPercent: limits?.cpu && limits.cpu > 0 ? limits.cpu : null, + memoryLimitBytes: limits?.memory ? limits.memory * 1024 * 1024 : null, + diskLimitBytes: limits?.disk ? limits.disk * 1024 * 1024 : null, + fetchedAt: Date.now(), + }; + this.limitsCache.set(serverId, entry); + return entry; + } + + async getServerStatus(serverId: string): Promise { + const resources = await this.getServerResources(serverId); + return resources.status; + } + + async getServerResources(serverId: string): Promise { + const data = await this.request<{ + attributes?: { + current_state?: string; + resources?: { + memory_bytes?: number; + cpu_absolute?: number; + disk_bytes?: number; + network_rx_bytes?: number; + network_tx_bytes?: number; + uptime?: number; + }; + }; + }>('resources', `/servers/${encodeURIComponent(serverId)}/resources`); + + const attrs = data.attributes ?? {}; + const res = attrs.resources ?? {}; + const limits = await this.getLimits(serverId).catch(() => ({ + cpuLimitPercent: null, + memoryLimitBytes: null, + diskLimitBytes: null, + })); + + return { + status: mapState(attrs.current_state ?? 'unknown'), + cpuPercent: res.cpu_absolute ?? 0, + cpuLimitPercent: limits.cpuLimitPercent, + memoryBytes: res.memory_bytes ?? 0, + memoryLimitBytes: limits.memoryLimitBytes, + diskBytes: res.disk_bytes ?? 0, + diskLimitBytes: limits.diskLimitBytes, + networkRxBytes: res.network_rx_bytes ?? 0, + networkTxBytes: res.network_tx_bytes ?? 0, + uptimeMs: res.uptime ?? 0, + }; + } + + private async sendPowerSignal(serverId: string, signal: 'start' | 'stop' | 'restart') { + await this.request(`power ${signal}`, `/servers/${encodeURIComponent(serverId)}/power`, { + method: 'POST', + body: { signal }, + }); + } + + async startServer(serverId: string): Promise { + await this.sendPowerSignal(serverId, 'start'); + } + + async stopServer(serverId: string): Promise { + await this.sendPowerSignal(serverId, 'stop'); + } + + async restartServer(serverId: string): Promise { + await this.sendPowerSignal(serverId, 'restart'); + } + + async listFiles(serverId: string, directory: string): Promise { + const data = await this.request<{ + data?: { + attributes?: { + name?: string; + is_file?: boolean; + size?: number; + modified_at?: string; + }; + }[]; + }>( + 'file list', + `/servers/${encodeURIComponent(serverId)}/files/list?directory=${encodeURIComponent(directory)}`, + ); + return (data.data ?? []).map((entry) => ({ + name: entry.attributes?.name ?? '', + isFile: entry.attributes?.is_file ?? false, + sizeBytes: entry.attributes?.size ?? 0, + modifiedAt: entry.attributes?.modified_at ? new Date(entry.attributes.modified_at) : null, + })); + } + + async getFileDownloadUrl(serverId: string, path: string): Promise { + const data = await this.request<{ attributes?: { url?: string } }>( + 'file download url', + `/servers/${encodeURIComponent(serverId)}/files/download?file=${encodeURIComponent(path)}`, + ); + const url = data.attributes?.url; + if (!url) { + throw ApiError.upstream('Pterodactyl did not return a download URL.'); + } + return url; + } + + /** + * Downloads a text file via the signed one-time download URL (streams and + * caps size, unlike files/contents which buffers whole files). When the file + * exceeds maxBytes the TAIL is kept — this method exists for log retrieval. + */ + async downloadTextFile( + serverId: string, + path: string, + maxBytes: number = DEFAULT_MAX_DOWNLOAD_BYTES, + ): Promise { + const stat = await this.statFile(serverId, path); + const url = await this.getFileDownloadUrl(serverId, path); + let response: Response; + try { + response = await this.fetchImpl(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }); + } catch (error) { + const reason = + error instanceof Error && error.name === 'TimeoutError' ? 'timed out' : 'failed'; + throw ApiError.upstream(`Pterodactyl log download ${reason}.`); + } + if (!response.ok || !response.body) { + throw ApiError.upstream(`Pterodactyl log download returned HTTP ${response.status}.`); + } + + // Stream and keep a rolling tail of at most maxBytes. + const chunks: Uint8Array[] = []; + let buffered = 0; + let discarded = 0; + const reader = response.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + buffered += value.byteLength; + while (buffered - (chunks[0]?.byteLength ?? 0) >= maxBytes && chunks.length > 1) { + const dropped = chunks.shift()!; + buffered -= dropped.byteLength; + discarded += dropped.byteLength; + } + } + let combined = Buffer.concat(chunks); + if (combined.byteLength > maxBytes) { + const trim = combined.byteLength - maxBytes; + combined = combined.subarray(trim); + discarded += trim; + } + + return { + path, + content: combined.toString('utf8'), + totalSizeBytes: stat?.sizeBytes ?? discarded + combined.byteLength, + contentStartOffset: discarded, + truncated: discarded > 0, + }; + } + + async writeTextFile(serverId: string, path: string, content: string): Promise { + const url = `${this.baseUrl}/api/client/servers/${encodeURIComponent(serverId)}/files/write?file=${encodeURIComponent(path)}`; + let response: Response; + try { + response = await this.fetchImpl(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + Accept: 'application/json', + 'Content-Type': 'text/plain', + }, + body: content, + signal: AbortSignal.timeout(this.timeoutMs), + }); + } catch (error) { + const reason = + error instanceof Error && error.name === 'TimeoutError' ? 'timed out' : 'failed'; + throw ApiError.upstream(`Pterodactyl request (file write) ${reason}.`); + } + if (!response.ok) { + throw ApiError.upstream(`Pterodactyl request (file write) returned HTTP ${response.status}.`); + } + } + + async listStartupVariables(serverId: string) { + const data = await this.request<{ + data?: { + attributes?: { + name?: string; + description?: string; + env_variable?: string; + server_value?: string | null; + default_value?: string | null; + is_editable?: boolean; + }; + }[]; + }>('startup variables', `/servers/${encodeURIComponent(serverId)}/startup`); + return (data.data ?? []).map((entry) => ({ + name: entry.attributes?.name ?? '', + description: entry.attributes?.description ?? '', + envVariable: entry.attributes?.env_variable ?? '', + serverValue: entry.attributes?.server_value ?? '', + defaultValue: entry.attributes?.default_value ?? '', + isEditable: entry.attributes?.is_editable ?? false, + })); + } + + async updateStartupVariable(serverId: string, envVariable: string, value: string): Promise { + await this.request( + 'startup variable update', + `/servers/${encodeURIComponent(serverId)}/startup/variable`, + { method: 'PUT', body: { key: envVariable, value } }, + ); + } + + private mapSchedule(attributes: PterodactylScheduleAttributes): ServerScheduleSummary { + const cron = attributes.cron ?? {}; + const tasks: ServerScheduleTask[] = (attributes.relationships?.tasks?.data ?? []).map( + (task) => ({ + id: String(task.attributes?.id ?? ''), + action: task.attributes?.action ?? '', + payload: task.attributes?.payload ?? '', + timeOffsetSeconds: task.attributes?.time_offset ?? 0, + continueOnFailure: task.attributes?.continue_on_failure ?? false, + }), + ); + return { + id: String(attributes.id ?? ''), + name: attributes.name ?? 'Untitled schedule', + isActive: attributes.is_active ?? false, + onlyWhenOnline: attributes.only_when_online ?? false, + minute: cron.minute ?? '*', + hour: cron.hour ?? '*', + dayOfMonth: cron.day_of_month ?? '*', + month: cron.month ?? '*', + dayOfWeek: cron.day_of_week ?? '*', + nextRunAt: attributes.next_run_at ?? null, + lastRunAt: attributes.last_run_at ?? null, + createdAt: attributes.created_at ?? null, + updatedAt: attributes.updated_at ?? null, + tasks, + }; + } + + private scheduleBody(input: RestartScheduleInput) { + return { + name: input.name, + is_active: input.isActive, + minute: String(input.minute), + hour: String(input.hour), + day_of_month: '*', + month: '*', + day_of_week: input.dayOfWeek, + only_when_online: input.onlyWhenOnline, + }; + } + + async listSchedules(serverId: string): Promise { + const data = await this.request<{ + data?: { attributes?: PterodactylScheduleAttributes }[]; + }>('schedules', `/servers/${encodeURIComponent(serverId)}/schedules?include=tasks`); + return (data.data ?? []).map((entry) => this.mapSchedule(entry.attributes ?? {})); + } + + async createRestartSchedule( + serverId: string, + input: RestartScheduleInput, + ): Promise { + const created = await this.request( + 'schedule create', + `/servers/${encodeURIComponent(serverId)}/schedules`, + { method: 'POST', body: this.scheduleBody(input) }, + ); + const schedule = this.mapSchedule(created.data?.attributes ?? {}); + if (!schedule.id) { + throw ApiError.upstream('Pterodactyl did not return the created schedule id.'); + } + await this.request( + 'schedule task create', + `/servers/${encodeURIComponent(serverId)}/schedules/${encodeURIComponent(schedule.id)}/tasks`, + { + method: 'POST', + body: { + action: 'power', + payload: 'restart', + time_offset: 0, + continue_on_failure: false, + }, + }, + ); + const [withTasks] = (await this.listSchedules(serverId)).filter((s) => s.id === schedule.id); + return withTasks ?? schedule; + } + + async updateRestartSchedule( + serverId: string, + scheduleId: string, + input: RestartScheduleInput, + ): Promise { + const updated = await this.request( + 'schedule update', + `/servers/${encodeURIComponent(serverId)}/schedules/${encodeURIComponent(scheduleId)}`, + { method: 'PATCH', body: this.scheduleBody(input) }, + ); + return this.mapSchedule(updated.data?.attributes ?? {}); + } + + async deleteSchedule(serverId: string, scheduleId: string): Promise { + await this.request( + 'schedule delete', + `/servers/${encodeURIComponent(serverId)}/schedules/${encodeURIComponent(scheduleId)}`, + { method: 'DELETE' }, + ); + } + + private async statFile( + serverId: string, + path: string, + ): Promise<{ sizeBytes: number; modifiedAt: Date | null } | null> { + const directory = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) || '/' : '/'; + const fileName = path.slice(path.lastIndexOf('/') + 1); + try { + const entries = await this.listFiles(serverId, directory); + const match = entries.find((entry) => entry.isFile && entry.name === fileName); + return match ? { sizeBytes: match.sizeBytes, modifiedAt: match.modifiedAt } : null; + } catch { + return null; + } + } +} diff --git a/apps/api/src/modules/pterodactyl/types.ts b/apps/api/src/modules/pterodactyl/types.ts new file mode 100644 index 0000000..9f63d89 --- /dev/null +++ b/apps/api/src/modules/pterodactyl/types.ts @@ -0,0 +1,86 @@ +import type { + RestartScheduleInput, + ServerScheduleSummary, + ServerStatus, +} from '@reforger-panel/shared'; + +export type ProviderServerResources = { + status: ServerStatus; + cpuPercent: number; + cpuLimitPercent: number | null; + memoryBytes: number; + memoryLimitBytes: number | null; + diskBytes: number; + diskLimitBytes: number | null; + networkRxBytes: number; + networkTxBytes: number; + uptimeMs: number; +}; + +export type ServerFileEntry = { + name: string; + isFile: boolean; + sizeBytes: number; + modifiedAt: Date | null; +}; + +export type DownloadableFile = { + path: string; + content: string; + /** Size of the file on the remote, if known (may exceed content length when capped). */ + totalSizeBytes: number | null; + /** Byte offset of content[0] within the remote file. Non-zero when the head was trimmed. */ + contentStartOffset: number; + truncated: boolean; +}; + +/** + * Abstraction over the game-server backend (Pterodactyl Client API in + * production, an in-process mock for local development). Deliberately narrow: + * no arbitrary writes, no console execution. + */ +export interface GameServerProvider { + getServerStatus(serverId: string): Promise; + getServerResources(serverId: string): Promise; + + startServer(serverId: string): Promise; + stopServer(serverId: string): Promise; + restartServer(serverId: string): Promise; + + listFiles(serverId: string, directory: string): Promise; + getFileDownloadUrl(serverId: string, path: string): Promise; + downloadTextFile(serverId: string, path: string, maxBytes?: number): Promise; + + /** + * Writes a text file. NOT exposed as a generic panel endpoint: the only + * callers write server-generated content to paths from server configuration + * (config.json updates and their backups), never user-supplied paths. + */ + writeTextFile(serverId: string, path: string, content: string): Promise; + + /** Egg startup variables (Pterodactyl "Startup" tab). May contain secrets. */ + listStartupVariables(serverId: string): Promise; + updateStartupVariable(serverId: string, envVariable: string, value: string): Promise; + + /** Native Pterodactyl schedules, scoped here to restart schedule management. */ + listSchedules(serverId: string): Promise; + createRestartSchedule( + serverId: string, + input: RestartScheduleInput, + ): Promise; + updateRestartSchedule( + serverId: string, + scheduleId: string, + input: RestartScheduleInput, + ): Promise; + deleteSchedule(serverId: string, scheduleId: string): Promise; +} + +export type StartupVariableEntry = { + name: string; + description: string; + envVariable: string; + serverValue: string; + defaultValue: string; + isEditable: boolean; +}; diff --git a/apps/api/src/modules/reforger-logs/ingestion/cursor-service.test.ts b/apps/api/src/modules/reforger-logs/ingestion/cursor-service.test.ts new file mode 100644 index 0000000..9e62eb6 --- /dev/null +++ b/apps/api/src/modules/reforger-logs/ingestion/cursor-service.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { BOUNDED_TAIL_BYTES, hashLine, planIngestion, type CursorState } from './cursor-service.js'; + +function snapshot(content: string, opts: { startOffset?: number; totalSize?: number } = {}) { + const buffer = Buffer.from(content, 'utf8'); + return { + buffer, + contentStartOffset: opts.startOffset ?? 0, + totalSizeBytes: opts.totalSize ?? (opts.startOffset ?? 0) + buffer.byteLength, + }; +} + +function cursorFor(consumed: string, extra: Partial = {}): CursorState { + const lines = consumed.endsWith('\n') ? consumed.slice(0, -1).split('\n') : consumed.split('\n'); + return { + fileFingerprint: null, + lastByteOffset: Buffer.byteLength(consumed, 'utf8'), + lastLineHash: hashLine(lines.at(-1) ?? ''), + partialTrailingLine: null, + ...extra, + }; +} + +describe('planIngestion', () => { + it('first sync processes the whole (small) file', () => { + const plan = planIngestion(null, snapshot('line1\nline2\n')); + expect(plan.reason).toBe('first_sync'); + expect(plan.cursorReset).toBe(false); + expect(plan.chunk).toBe('line1\nline2\n'); + expect(plan.nextByteOffset).toBe(12); + }); + + it('first sync bounds a huge file to a tail starting at a line boundary', () => { + const bigLine = 'x'.repeat(1000) + '\n'; + const content = bigLine.repeat(600); // ~600 KB > BOUNDED_TAIL_BYTES + const plan = planIngestion(null, snapshot(content)); + expect(Buffer.byteLength(plan.chunk)).toBeLessThanOrEqual(BOUNDED_TAIL_BYTES); + expect(plan.chunk.startsWith('x')).toBe(true); + expect(plan.chunk.endsWith('\n')).toBe(true); + expect(plan.nextByteOffset).toBe(Buffer.byteLength(content)); + }); + + it('normal append processes only new content', () => { + const consumed = 'line1\nline2\n'; + const appended = 'line3\nline4\n'; + const plan = planIngestion(cursorFor(consumed), snapshot(consumed + appended)); + expect(plan.reason).toBe('append'); + expect(plan.chunk).toBe(appended); + expect(plan.cursorReset).toBe(false); + }); + + it('prepends a stored partial trailing line to new content', () => { + const consumed = 'line1\npart'; + const cursor = cursorFor(consumed, { + partialTrailingLine: 'part', + lastLineHash: hashLine('line1'), + }); + const plan = planIngestion(cursor, snapshot('line1\npartial-done\nline3\n')); + expect(plan.reason).toBe('append'); + expect(plan.chunk).toBe('partial-done\nline3\n'); + }); + + it('reports no new data when the file has not grown', () => { + const consumed = 'line1\nline2\n'; + const plan = planIngestion(cursorFor(consumed), snapshot(consumed)); + expect(plan.reason).toBe('no_new_data'); + expect(plan.chunk).toBe(''); + expect(plan.cursorReset).toBe(false); + }); + + it('resets on rotation (file shrank)', () => { + const cursor = cursorFor('a'.repeat(5000) + '\n'); + const plan = planIngestion(cursor, snapshot('fresh1\nfresh2\n')); + expect(plan.reason).toBe('rotation'); + expect(plan.cursorReset).toBe(true); + expect(plan.chunk).toBe('fresh1\nfresh2\n'); + }); + + it('resets when the fingerprint (first line) changed despite a larger file', () => { + const oldContent = 'old-header\nold-line\n'; + const cursor = cursorFor(oldContent, { fileFingerprint: hashLine('old-header') }); + const newContent = 'new-header-longer\nnew-line-1\nnew-line-2\n'; + const plan = planIngestion(cursor, snapshot(newContent)); + expect(plan.reason).toBe('rotation'); + expect(plan.cursorReset).toBe(true); + }); + + it('resets on continuity mismatch (replaced file, same-or-larger size, no visible head)', () => { + const consumed = 'line1\nline2\n'; + const cursor = cursorFor(consumed); + // Same length as consumed but different content before the cut. + const replaced = 'lineX\nlineZ\nline3\n'; + const plan = planIngestion(cursor, snapshot(replaced, { startOffset: 0, totalSize: 100 })); + // fingerprint check triggers first only if cursor had one; here continuity check fires + expect(['continuity_mismatch', 'rotation']).toContain(plan.reason); + expect(plan.cursorReset).toBe(true); + }); + + it('processes a bounded tail when the download window skipped past the cursor', () => { + const cursor = cursorFor('early\n'); // offset 6 + const plan = planIngestion( + cursor, + snapshot('tail-line-1\ntail-line-2\n', { startOffset: 10_000, totalSize: 10_024 }), + ); + expect(plan.reason).toBe('gap'); + expect(plan.cursorReset).toBe(true); + // Head-cut downloads drop the first partial line. + expect(plan.chunk).toBe('tail-line-2\n'); + }); + + it('advances the cursor across consecutive appends', () => { + let content = 'l1\n'; + let cursor: CursorState | null = null; + const offsets: number[] = []; + for (const next of ['l2\n', 'l3\n', 'l4\n']) { + const plan = planIngestion(cursor, snapshot(content)); + offsets.push(plan.nextByteOffset); + const lines = content.slice(0, plan.nextByteOffset); + cursor = cursorFor(lines, { fileFingerprint: null }); + content += next; + } + expect(offsets).toEqual([3, 6, 9]); + }); +}); diff --git a/apps/api/src/modules/reforger-logs/ingestion/cursor-service.ts b/apps/api/src/modules/reforger-logs/ingestion/cursor-service.ts new file mode 100644 index 0000000..b45df1e --- /dev/null +++ b/apps/api/src/modules/reforger-logs/ingestion/cursor-service.ts @@ -0,0 +1,145 @@ +import { sha256Hex } from '../../../lib/crypto.js'; + +/** How much history to import when seeing a file for the first time (or after rotation). */ +export const BOUNDED_TAIL_BYTES = 512 * 1024; + +export type CursorState = { + fileFingerprint: string | null; + lastByteOffset: number; + lastLineHash: string | null; + partialTrailingLine: string | null; +}; + +export type FileSnapshot = { + /** Raw downloaded bytes (possibly only the tail of the remote file). */ + buffer: Buffer; + /** Byte offset of buffer[0] within the remote file. */ + contentStartOffset: number; + /** Total remote file size if known. */ + totalSizeBytes: number | null; +}; + +export type IngestionPlan = { + /** Text to parse this sync, starting at a line boundary. */ + chunk: string; + /** Cursor byte offset to record after a successful parse. */ + nextByteOffset: number; + /** True when the cursor was reset (first sync, rotation, truncation, or mismatch). */ + cursorReset: boolean; + reason: 'first_sync' | 'append' | 'no_new_data' | 'rotation' | 'continuity_mismatch' | 'gap'; +}; + +export function hashLine(line: string): string { + return sha256Hex(line); +} + +/** Extract the final complete line of a buffer region (for continuity checks). */ +function lastCompleteLineBefore(buffer: Buffer, end: number): string | null { + if (end <= 0) return null; + const region = buffer.subarray(0, end); + const text = region.toString('utf8'); + const withoutTrailing = text.endsWith('\n') ? text.slice(0, -1) : text; + const lastNewline = withoutTrailing.lastIndexOf('\n'); + const line = lastNewline >= 0 ? withoutTrailing.slice(lastNewline + 1) : withoutTrailing; + return line.replace(/\r$/, ''); +} + +/** Skip a leading partial line after an arbitrary byte cut. */ +function alignToNextLine(buffer: Buffer): Buffer { + const newlineIndex = buffer.indexOf(0x0a); + if (newlineIndex === -1) return Buffer.alloc(0); + return buffer.subarray(newlineIndex + 1); +} + +function boundedTail(snapshot: FileSnapshot, reason: IngestionPlan['reason']): IngestionPlan { + let region = snapshot.buffer; + let cutInsideLine = snapshot.contentStartOffset > 0; + if (region.byteLength > BOUNDED_TAIL_BYTES) { + region = region.subarray(region.byteLength - BOUNDED_TAIL_BYTES); + cutInsideLine = true; + } + if (cutInsideLine) { + region = alignToNextLine(region); + } + return { + chunk: region.toString('utf8'), + nextByteOffset: snapshot.contentStartOffset + snapshot.buffer.byteLength, + cursorReset: reason !== 'first_sync', + reason, + }; +} + +/** + * Decide what portion of the downloaded file to parse, handling first sync, + * normal append, rotation/truncation/replacement, and download gaps. + * + * The fingerprint is the hash of the file's first line when the download + * includes the start of the file; it changes when the file is replaced even + * if the new file is larger than the old offset. + */ +export function planIngestion(cursor: CursorState | null, snapshot: FileSnapshot): IngestionPlan { + const fileEnd = snapshot.contentStartOffset + snapshot.buffer.byteLength; + + if (!cursor) { + return boundedTail(snapshot, 'first_sync'); + } + + const totalSize = snapshot.totalSizeBytes ?? fileEnd; + + // Rotation / truncation: the file shrank below what we already consumed. + if (totalSize < cursor.lastByteOffset) { + return boundedTail(snapshot, 'rotation'); + } + + // Replacement detection via fingerprint (only when we can see the file head). + const fingerprint = computeFingerprint(snapshot); + if (fingerprint && cursor.fileFingerprint && fingerprint !== cursor.fileFingerprint) { + return boundedTail(snapshot, 'rotation'); + } + + // The download window no longer reaches back to our cursor (file grew more + // than maxBytes between syncs). Process what we have; some lines were lost. + if (cursor.lastByteOffset < snapshot.contentStartOffset) { + return boundedTail(snapshot, 'gap'); + } + + const cutIndex = cursor.lastByteOffset - snapshot.contentStartOffset; + if (cutIndex >= snapshot.buffer.byteLength) { + return { + chunk: '', + nextByteOffset: cursor.lastByteOffset, + cursorReset: false, + reason: 'no_new_data', + }; + } + + // Continuity check: the content just before the cut must be what we last + // saw; otherwise the file was replaced by a same-size-or-larger one. + if (cursor.partialTrailingLine !== null && cursor.partialTrailingLine !== '') { + const fragment = lastCompleteLineBefore(snapshot.buffer, cutIndex); + if (fragment !== null && cutIndex > 0 && !cursor.partialTrailingLine.endsWith(fragment)) { + return boundedTail(snapshot, 'continuity_mismatch'); + } + } else if (cursor.lastLineHash) { + const previousLine = lastCompleteLineBefore(snapshot.buffer, cutIndex); + if (previousLine !== null && hashLine(previousLine) !== cursor.lastLineHash) { + return boundedTail(snapshot, 'continuity_mismatch'); + } + } + + const newRegion = snapshot.buffer.subarray(cutIndex); + const chunk = (cursor.partialTrailingLine ?? '') + newRegion.toString('utf8'); + return { + chunk, + nextByteOffset: fileEnd, + cursorReset: false, + reason: 'append', + }; +} + +export function computeFingerprint(snapshot: FileSnapshot): string | null { + if (snapshot.contentStartOffset !== 0) return null; + const firstNewline = snapshot.buffer.indexOf(0x0a); + if (firstNewline === -1) return null; + return sha256Hex(snapshot.buffer.subarray(0, firstNewline).toString('utf8')); +} diff --git a/apps/api/src/modules/reforger-logs/ingestion/drizzle-store.ts b/apps/api/src/modules/reforger-logs/ingestion/drizzle-store.ts new file mode 100644 index 0000000..25e3858 --- /dev/null +++ b/apps/api/src/modules/reforger-logs/ingestion/drizzle-store.ts @@ -0,0 +1,196 @@ +import { and, eq, isNull } from 'drizzle-orm'; +import type { Db } from '../../../db/client.js'; +import { schema } from '../../../db/client.js'; +import type { + CursorRecord, + IngestionStore, + NewServerEvent, + OpenSessionRecord, + PlayerRecord, +} from './types.js'; + +export class DrizzleIngestionStore implements IngestionStore { + constructor(private readonly db: Db) {} + + async getCursor(serverId: string, logPath: string): Promise { + const rows = await this.db + .select() + .from(schema.logCursors) + .where(and(eq(schema.logCursors.serverId, serverId), eq(schema.logCursors.logPath, logPath))); + const row = rows[0]; + if (!row) return null; + return { + serverId: row.serverId, + logPath: row.logPath, + fileFingerprint: row.fileFingerprint, + lastByteOffset: row.lastByteOffset, + lastLineHash: row.lastLineHash, + partialTrailingLine: row.partialTrailingLine, + lastEventTimestamp: row.lastEventTimestamp, + lastSuccessfulSyncAt: row.lastSuccessfulSyncAt, + lastErrorAt: row.lastErrorAt, + lastErrorMessage: row.lastErrorMessage, + }; + } + + async saveCursor(cursor: CursorRecord): Promise { + await this.db + .insert(schema.logCursors) + .values(cursor) + .onConflictDoUpdate({ + target: [schema.logCursors.serverId, schema.logCursors.logPath], + set: { + fileFingerprint: cursor.fileFingerprint, + lastByteOffset: cursor.lastByteOffset, + lastLineHash: cursor.lastLineHash, + partialTrailingLine: cursor.partialTrailingLine, + lastEventTimestamp: cursor.lastEventTimestamp, + lastSuccessfulSyncAt: cursor.lastSuccessfulSyncAt, + lastErrorAt: cursor.lastErrorAt, + lastErrorMessage: cursor.lastErrorMessage, + }, + }); + } + + async insertEventIfNew( + event: NewServerEvent, + ): Promise<{ created: boolean; eventId: string | null }> { + const rows = await this.db + .insert(schema.serverEvents) + .values({ + serverId: event.serverId, + eventType: event.eventType, + occurredAt: event.occurredAt, + playerId: event.playerId ?? null, + playerSessionId: event.playerSessionId ?? null, + summary: event.summary, + payload: event.payload, + sourceLogPath: event.sourceLogPath, + sourceLineHash: event.sourceLineHash, + }) + .onConflictDoNothing({ + target: [ + schema.serverEvents.serverId, + schema.serverEvents.sourceLogPath, + schema.serverEvents.sourceLineHash, + ], + }) + .returning({ id: schema.serverEvents.id }); + return { created: rows.length > 0, eventId: rows[0]?.id ?? null }; + } + + async findPlayerByExternalId( + serverId: string, + externalPlayerId: string, + ): Promise { + const rows = await this.db + .select() + .from(schema.players) + .where( + and( + eq(schema.players.serverId, serverId), + eq(schema.players.externalPlayerId, externalPlayerId), + ), + ); + return rows[0] ?? null; + } + + async findPlayerByName(serverId: string, displayName: string): Promise { + const rows = await this.db + .select() + .from(schema.players) + .where( + and(eq(schema.players.serverId, serverId), eq(schema.players.displayName, displayName)), + ) + .limit(1); + return rows[0] ?? null; + } + + async createPlayer(input: { + serverId: string; + displayName: string; + externalPlayerId: string | null; + seenAt: Date; + }): Promise { + const [row] = await this.db + .insert(schema.players) + .values({ + serverId: input.serverId, + displayName: input.displayName, + externalPlayerId: input.externalPlayerId, + firstSeenAt: input.seenAt, + lastSeenAt: input.seenAt, + }) + .returning(); + return row!; + } + + async updatePlayer( + playerId: string, + patch: { externalPlayerId?: string; displayName?: string; lastSeenAt?: Date }, + ): Promise { + await this.db.update(schema.players).set(patch).where(eq(schema.players.id, playerId)); + } + + async getOpenSession(serverId: string, playerId: string): Promise { + const rows = await this.db + .select() + .from(schema.playerSessions) + .where( + and( + eq(schema.playerSessions.serverId, serverId), + eq(schema.playerSessions.playerId, playerId), + isNull(schema.playerSessions.disconnectedAt), + ), + ); + const row = rows[0]; + return row ? { id: row.id, playerId: row.playerId, connectedAt: row.connectedAt } : null; + } + + async openSession(input: { + serverId: string; + playerId: string; + connectedAt: Date; + sourceLogPath: string; + }): Promise { + const [row] = await this.db.insert(schema.playerSessions).values(input).returning(); + return { id: row!.id, playerId: row!.playerId, connectedAt: row!.connectedAt }; + } + + async closeSession( + sessionId: string, + input: { disconnectedAt: Date; durationSeconds: number; disconnectReason: string | null }, + ): Promise { + await this.db + .update(schema.playerSessions) + .set(input) + .where(eq(schema.playerSessions.id, sessionId)); + } + + async closeAllOpenSessions( + serverId: string, + disconnectedAt: Date, + reason: string, + ): Promise<{ closed: number }> { + const open = await this.db + .select() + .from(schema.playerSessions) + .where( + and( + eq(schema.playerSessions.serverId, serverId), + isNull(schema.playerSessions.disconnectedAt), + ), + ); + for (const session of open) { + await this.closeSession(session.id, { + disconnectedAt, + durationSeconds: Math.max( + 0, + Math.round((disconnectedAt.getTime() - session.connectedAt.getTime()) / 1000), + ), + disconnectReason: reason, + }); + } + return { closed: open.length }; + } +} diff --git a/apps/api/src/modules/reforger-logs/ingestion/ingestion-service.ts b/apps/api/src/modules/reforger-logs/ingestion/ingestion-service.ts new file mode 100644 index 0000000..a67a1a2 --- /dev/null +++ b/apps/api/src/modules/reforger-logs/ingestion/ingestion-service.ts @@ -0,0 +1,371 @@ +import type { LogSyncResult } from '@reforger-panel/shared'; +import { sanitizeErrorMessage, type Logger } from '../../../lib/logger.js'; +import { parseLogChunk } from '../parser/parser.js'; +import type { ParsedLogEvent } from '../parser/types.js'; +import { computeFingerprint, hashLine, planIngestion } from './cursor-service.js'; +import { dateFromLogPath } from './log-path-resolver.js'; +import type { CursorRecord, IngestionStore, LogSource, PlayerRecord } from './types.js'; + +export type IngestionOptions = { + maxDownloadBytes: number; +}; + +export type SyncStats = LogSyncResult & { + ignoredLines: number; + invalidTimestamps: number; + reason: string; +}; + +/** + * Turns raw Reforger log content into player/session/event records. + * Orchestrates: fetch (LogSource) → plan (cursor-service) → parse (parser) → + * persist (IngestionStore). Holds no state between runs beyond the cursor. + */ +export class LogIngestionService { + constructor( + private readonly source: LogSource, + private readonly store: IngestionStore, + private readonly logger: Logger, + private readonly options: IngestionOptions, + ) {} + + async sync(serverId: string, providerServerId: string, logPath: string): Promise { + const startedAt = new Date(); + try { + const stats = await this.runSync(serverId, providerServerId, logPath, startedAt); + this.logger.debug({ ...stats }, 'log sync completed'); + return stats; + } catch (error) { + const message = sanitizeErrorMessage(error); + await this.recordFailure(serverId, logPath, message).catch(() => undefined); + this.logger.warn({ serverId, logPath, error: message }, 'log sync failed'); + throw error; + } + } + + private async runSync( + serverId: string, + providerServerId: string, + logPath: string, + startedAt: Date, + ): Promise { + const file = await this.source.fetchLog( + providerServerId, + logPath, + this.options.maxDownloadBytes, + ); + const buffer = Buffer.from(file.content, 'utf8'); + const snapshot = { + buffer, + contentStartOffset: file.contentStartOffset, + totalSizeBytes: file.totalSizeBytes, + }; + + const cursor = await this.store.getCursor(serverId, logPath); + const plan = planIngestion(cursor, snapshot); + + // Continuation chunks have no "Log started" header, so carry the calendar + // date forward from the last ingested event — or, failing that, from the + // dated per-boot folder name in the log path. A header in the chunk + // (fresh file after rotation) still overrides this. + const previousTimestamp = + (!plan.cursorReset ? (cursor?.lastEventTimestamp ?? null) : null) ?? dateFromLogPath(logPath); + const parsed = parseLogChunk(plan.chunk, { + fallbackDate: new Date(), + context: previousTimestamp + ? { + baseDate: new Date( + Date.UTC( + previousTimestamp.getUTCFullYear(), + previousTimestamp.getUTCMonth(), + previousTimestamp.getUTCDate(), + ), + ), + lastTimestamp: previousTimestamp, + } + : undefined, + }); + + let createdEvents = 0; + let updatedSessions = 0; + for (const event of parsed.events) { + const result = await this.applyEvent(serverId, logPath, event); + createdEvents += result.createdEvents; + updatedSessions += result.updatedSessions; + } + + const lastEvent = parsed.events.at(-1); + const fingerprint = + computeFingerprint(snapshot) ?? (plan.cursorReset ? null : (cursor?.fileFingerprint ?? null)); + const nextCursor: CursorRecord = { + serverId, + logPath, + fileFingerprint: fingerprint, + lastByteOffset: plan.nextByteOffset, + lastLineHash: parsed.lastCompleteLine + ? hashLine(parsed.lastCompleteLine) + : plan.cursorReset || plan.reason === 'first_sync' + ? null + : (cursor?.lastLineHash ?? null), + partialTrailingLine: + parsed.partialTrailingLine ?? + (plan.reason === 'no_new_data' ? (cursor?.partialTrailingLine ?? null) : null), + lastEventTimestamp: lastEvent?.occurredAt ?? cursor?.lastEventTimestamp ?? null, + lastSuccessfulSyncAt: new Date(), + lastErrorAt: null, + lastErrorMessage: null, + }; + await this.store.saveCursor(nextCursor); + + return { + serverId, + logPath, + fetchedBytes: buffer.byteLength, + processedLines: parsed.completeLineCount, + createdEvents, + updatedSessions, + cursorReset: plan.cursorReset, + startedAt: startedAt.toISOString(), + finishedAt: new Date().toISOString(), + ignoredLines: parsed.ignoredLineCount, + invalidTimestamps: parsed.invalidTimestampCount, + reason: plan.reason, + }; + } + + private async recordFailure(serverId: string, logPath: string, message: string): Promise { + const cursor = await this.store.getCursor(serverId, logPath); + await this.store.saveCursor({ + serverId, + logPath, + fileFingerprint: cursor?.fileFingerprint ?? null, + lastByteOffset: cursor?.lastByteOffset ?? 0, + lastLineHash: cursor?.lastLineHash ?? null, + partialTrailingLine: cursor?.partialTrailingLine ?? null, + lastEventTimestamp: cursor?.lastEventTimestamp ?? null, + lastSuccessfulSyncAt: cursor?.lastSuccessfulSyncAt ?? null, + lastErrorAt: new Date(), + lastErrorMessage: message, + }); + } + + private async resolvePlayer( + serverId: string, + event: Extract< + ParsedLogEvent, + { type: 'player_connected' | 'player_disconnected' | 'player_identity' } + >, + ): Promise { + // Prefer the stable log-provided identity; fall back to display name. + // Names are NOT globally unique — see README for the limitations. + if (event.type === 'player_identity' || event.externalPlayerId) { + const externalId = event.externalPlayerId!; + const byExternal = await this.store.findPlayerByExternalId(serverId, externalId); + if (byExternal) { + if (byExternal.displayName !== event.playerName) { + await this.store.updatePlayer(byExternal.id, { + displayName: event.playerName, + lastSeenAt: event.occurredAt, + }); + } + return byExternal; + } + const byName = await this.store.findPlayerByName(serverId, event.playerName); + if (byName && byName.externalPlayerId === null) { + await this.store.updatePlayer(byName.id, { + externalPlayerId: externalId, + lastSeenAt: event.occurredAt, + }); + return { ...byName, externalPlayerId: externalId }; + } + if (byName) { + // The player already carries a different identity (e.g. engine + // identityId vs BattlEye GUID — the logs emit both). Keep the first + // one rather than splitting the player into duplicates. + await this.store.updatePlayer(byName.id, { lastSeenAt: event.occurredAt }); + return byName; + } + return this.store.createPlayer({ + serverId, + displayName: event.playerName, + externalPlayerId: externalId, + seenAt: event.occurredAt, + }); + } + + const byName = await this.store.findPlayerByName(serverId, event.playerName); + if (byName) { + await this.store.updatePlayer(byName.id, { lastSeenAt: event.occurredAt }); + return byName; + } + return this.store.createPlayer({ + serverId, + displayName: event.playerName, + externalPlayerId: null, + seenAt: event.occurredAt, + }); + } + + private async resolvePlayerByName( + serverId: string, + playerName: string, + occurredAt: Date, + ): Promise { + const byName = await this.store.findPlayerByName(serverId, playerName); + if (byName) { + await this.store.updatePlayer(byName.id, { lastSeenAt: occurredAt }); + return byName; + } + return this.store.createPlayer({ + serverId, + displayName: playerName, + externalPlayerId: null, + seenAt: occurredAt, + }); + } + + private async applyEvent( + serverId: string, + logPath: string, + event: ParsedLogEvent, + ): Promise<{ createdEvents: number; updatedSessions: number }> { + const lineHash = hashLine(event.rawLine); + + switch (event.type) { + case 'player_connected': { + const player = await this.resolvePlayer(serverId, event); + const inserted = await this.store.insertEventIfNew({ + serverId, + eventType: 'player_connected', + occurredAt: event.occurredAt, + playerId: player.id, + summary: `${event.playerName} connected`, + payload: { playerName: event.playerName, playerNumber: event.playerNumber ?? null }, + sourceLogPath: logPath, + sourceLineHash: lineHash, + }); + if (!inserted.created) return { createdEvents: 0, updatedSessions: 0 }; + + // A connect while a session is open means we missed the disconnect. + const existing = await this.store.getOpenSession(serverId, player.id); + let updatedSessions = 0; + if (existing) { + await this.store.closeSession(existing.id, { + disconnectedAt: event.occurredAt, + durationSeconds: Math.max( + 0, + Math.round((event.occurredAt.getTime() - existing.connectedAt.getTime()) / 1000), + ), + disconnectReason: 'missed_disconnect', + }); + updatedSessions += 1; + } + await this.store.openSession({ + serverId, + playerId: player.id, + connectedAt: event.occurredAt, + sourceLogPath: logPath, + }); + return { createdEvents: 1, updatedSessions: updatedSessions + 1 }; + } + + case 'player_identity': { + // Identity lines only enrich the player record; they are not events. + await this.resolvePlayer(serverId, event); + return { createdEvents: 0, updatedSessions: 0 }; + } + + case 'player_disconnected': { + const player = await this.resolvePlayer(serverId, event); + const inserted = await this.store.insertEventIfNew({ + serverId, + eventType: 'player_disconnected', + occurredAt: event.occurredAt, + playerId: player.id, + summary: event.reason + ? `${event.playerName} disconnected (${event.reason})` + : `${event.playerName} disconnected`, + payload: { playerName: event.playerName, reason: event.reason ?? null }, + sourceLogPath: logPath, + sourceLineHash: lineHash, + }); + if (!inserted.created) return { createdEvents: 0, updatedSessions: 0 }; + + const open = await this.store.getOpenSession(serverId, player.id); + if (!open) return { createdEvents: 1, updatedSessions: 0 }; + await this.store.closeSession(open.id, { + disconnectedAt: event.occurredAt, + durationSeconds: Math.max( + 0, + Math.round((event.occurredAt.getTime() - open.connectedAt.getTime()) / 1000), + ), + disconnectReason: event.reason ?? null, + }); + return { createdEvents: 1, updatedSessions: 1 }; + } + + case 'player_killed': { + const killer = await this.resolvePlayerByName(serverId, event.killerName, event.occurredAt); + const victim = await this.resolvePlayerByName(serverId, event.victimName, event.occurredAt); + const inserted = await this.store.insertEventIfNew({ + serverId, + eventType: 'player_killed', + occurredAt: event.occurredAt, + playerId: victim.id, + summary: `${event.killerName} killed ${event.victimName}`, + payload: { + killerPlayerId: killer.id, + killerName: event.killerName, + victimPlayerId: victim.id, + victimName: event.victimName, + friendly: event.friendly, + killerTeam: null, + victimTeam: null, + killerPosition: null, + victimPosition: null, + distanceMeters: null, + weapon: null, + }, + sourceLogPath: logPath, + sourceLineHash: lineHash, + }); + return { createdEvents: inserted.created ? 1 : 0, updatedSessions: 0 }; + } + + case 'server_started': { + const inserted = await this.store.insertEventIfNew({ + serverId, + eventType: 'server_started', + occurredAt: event.occurredAt, + summary: 'Server started', + payload: {}, + sourceLogPath: logPath, + sourceLineHash: lineHash, + }); + if (!inserted.created) return { createdEvents: 0, updatedSessions: 0 }; + + // Sessions can't survive a server start; anything still open was + // orphaned by a crash/restart we didn't see a disconnect for. + const { closed } = await this.store.closeAllOpenSessions( + serverId, + event.occurredAt, + 'server_restart', + ); + let createdEvents = 1; + if (closed > 0) { + const restartInserted = await this.store.insertEventIfNew({ + serverId, + eventType: 'server_restart_detected', + occurredAt: event.occurredAt, + summary: `Server restart detected (${closed} session${closed === 1 ? '' : 's'} closed)`, + payload: { closedSessions: closed }, + sourceLogPath: logPath, + sourceLineHash: `${lineHash}:restart`, + }); + if (restartInserted.created) createdEvents += 1; + } + return { createdEvents, updatedSessions: closed }; + } + } + } +} diff --git a/apps/api/src/modules/reforger-logs/ingestion/log-path-resolver.test.ts b/apps/api/src/modules/reforger-logs/ingestion/log-path-resolver.test.ts new file mode 100644 index 0000000..d9c04dd --- /dev/null +++ b/apps/api/src/modules/reforger-logs/ingestion/log-path-resolver.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { createLogPathResolver, dateFromLogPath } from './log-path-resolver.js'; +import type { GameServerProvider, ServerFileEntry } from '../../pterodactyl/types.js'; + +function providerWithListing(entries: ServerFileEntry[]): GameServerProvider { + return { + listFiles: async () => entries, + } as unknown as GameServerProvider; +} + +function dir(name: string, modifiedAt: Date | null): ServerFileEntry { + return { name, isFile: false, sizeBytes: 0, modifiedAt }; +} + +describe('dateFromLogPath', () => { + it('extracts the session start time from dated folder names', () => { + expect( + dateFromLogPath('/profile/logs/logs_2026-07-04_12-54-04/console.log')?.toISOString(), + ).toBe('2026-07-04T12:54:04.000Z'); + }); + + it('returns null for paths without a dated folder', () => { + expect(dateFromLogPath('/profile/logs/console.log')).toBeNull(); + }); +}); + +describe('createLogPathResolver', () => { + it('uses the explicit path when configured, without listing files', async () => { + const resolve = createLogPathResolver({ + provider: providerWithListing([]), + providerServerId: 'x', + explicitPath: '/profile/logs/pinned.log', + directory: '/profile/logs', + fileName: 'console.log', + }); + expect(await resolve()).toBe('/profile/logs/pinned.log'); + }); + + it('picks the newest dated logs_* folder', async () => { + const resolve = createLogPathResolver({ + provider: providerWithListing([ + dir('logs_2026-07-04_12-54-04', new Date('2026-07-04T12:54:04Z')), + dir('logs_2026-07-05_08-10-00', new Date('2026-07-05T08:10:00Z')), + dir('backups', new Date('2026-07-05T09:00:00Z')), + ]), + providerServerId: 'x', + explicitPath: '', + directory: '/profile/logs', + fileName: 'console.log', + }); + expect(await resolve()).toBe('/profile/logs/logs_2026-07-05_08-10-00/console.log'); + }); + + it('falls back to name ordering when modified times are missing', async () => { + const resolve = createLogPathResolver({ + provider: providerWithListing([ + dir('logs_2026-07-03_23-00-00', null), + dir('logs_2026-07-05_01-00-00', null), + ]), + providerServerId: 'x', + explicitPath: '', + directory: '/profile/logs', + fileName: 'console.log', + }); + expect(await resolve()).toBe('/profile/logs/logs_2026-07-05_01-00-00/console.log'); + }); + + it('prefers a stable file directly in the directory', async () => { + const resolve = createLogPathResolver({ + provider: providerWithListing([ + { name: 'console.log', isFile: true, sizeBytes: 10, modifiedAt: new Date() }, + dir('logs_2026-07-05_01-00-00', new Date()), + ]), + providerServerId: 'x', + explicitPath: '', + directory: '/profile/logs/', + fileName: 'console.log', + }); + expect(await resolve()).toBe('/profile/logs/console.log'); + }); + + it('returns null when nothing matches', async () => { + const resolve = createLogPathResolver({ + provider: providerWithListing([dir('backups', new Date())]), + providerServerId: 'x', + explicitPath: '', + directory: '/profile/logs', + fileName: 'console.log', + }); + expect(await resolve()).toBeNull(); + }); +}); diff --git a/apps/api/src/modules/reforger-logs/ingestion/log-path-resolver.ts b/apps/api/src/modules/reforger-logs/ingestion/log-path-resolver.ts new file mode 100644 index 0000000..4ab2075 --- /dev/null +++ b/apps/api/src/modules/reforger-logs/ingestion/log-path-resolver.ts @@ -0,0 +1,73 @@ +import type { GameServerProvider } from '../../pterodactyl/types.js'; + +export type LogPathResolver = () => Promise; + +/** Directories the Reforger server creates per boot, e.g. logs_2026-07-04_12-54-04. */ +const DATED_LOG_DIR_PATTERN = /^logs[_-]/i; + +const LOG_PATH_DATE_PATTERN = /logs[_-](\d{4})-(\d{2})-(\d{2})[_-](\d{2})-(\d{2})-(\d{2})/i; + +/** + * Reforger's per-boot folder names encode the session start time; use it to + * anchor line timestamps when the parsed chunk has no "Log started" header + * and no prior cursor context. + */ +export function dateFromLogPath(logPath: string): Date | null { + const match = LOG_PATH_DATE_PATTERN.exec(logPath); + if (!match) return null; + const [, year, month, day, hours, minutes, seconds] = match; + const date = new Date( + Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hours), + Number(minutes), + Number(seconds), + ), + ); + return Number.isNaN(date.getTime()) ? null : date; +} + +/** + * Resolves the current Reforger log file path. + * + * - `REFORGER_ADMIN_LOG_PATH` (explicit file) wins when set. + * - Otherwise `REFORGER_LOG_DIRECTORY` is listed on every sync and the newest + * dated `logs_*` subdirectory is used, so per-boot log folders are picked up + * automatically after restarts. `REFORGER_LOG_FILE_PATTERN` is the file name + * inside that directory (default `console.log`). + */ +export function createLogPathResolver(options: { + provider: GameServerProvider; + providerServerId: string; + explicitPath: string; + directory: string; + fileName: string; +}): LogPathResolver { + const fileName = options.fileName || 'console.log'; + return async () => { + if (options.explicitPath) return options.explicitPath; + if (!options.directory) return null; + const directory = options.directory.replace(/\/$/, ''); + + const entries = await options.provider.listFiles(options.providerServerId, directory); + + // A stable file directly in the directory takes priority. + if (entries.some((entry) => entry.isFile && entry.name === fileName)) { + return `${directory}/${fileName}`; + } + + const datedDirs = entries.filter( + (entry) => !entry.isFile && DATED_LOG_DIR_PATTERN.test(entry.name), + ); + if (datedDirs.length === 0) return null; + datedDirs.sort((a, b) => { + const byTime = (b.modifiedAt?.getTime() ?? 0) - (a.modifiedAt?.getTime() ?? 0); + // Names embed sortable timestamps (logs_YYYY-MM-DD_HH-MM-SS); use them + // as a tiebreaker when mtimes are missing or equal. + return byTime !== 0 ? byTime : b.name.localeCompare(a.name); + }); + return `${directory}/${datedDirs[0]!.name}/${fileName}`; + }; +} diff --git a/apps/api/src/modules/reforger-logs/ingestion/pterodactyl-log-source.ts b/apps/api/src/modules/reforger-logs/ingestion/pterodactyl-log-source.ts new file mode 100644 index 0000000..faed5aa --- /dev/null +++ b/apps/api/src/modules/reforger-logs/ingestion/pterodactyl-log-source.ts @@ -0,0 +1,15 @@ +import type { GameServerProvider } from '../../pterodactyl/types.js'; +import type { LogSource } from './types.js'; + +/** + * LogSource backed by the game server provider (Pterodactyl Client API or the + * mock). Retrieval is size-capped tail download; if the panel ever needs + * range/tail requests, only this adapter changes. + */ +export class PterodactylLogSource implements LogSource { + constructor(private readonly provider: GameServerProvider) {} + + fetchLog(serverId: string, logPath: string, maxBytes: number) { + return this.provider.downloadTextFile(serverId, logPath, maxBytes); + } +} diff --git a/apps/api/src/modules/reforger-logs/ingestion/scheduler.ts b/apps/api/src/modules/reforger-logs/ingestion/scheduler.ts new file mode 100644 index 0000000..25977d8 --- /dev/null +++ b/apps/api/src/modules/reforger-logs/ingestion/scheduler.ts @@ -0,0 +1,120 @@ +import { ApiError } from '../../../lib/errors.js'; +import type { Logger } from '../../../lib/logger.js'; +import type { LogIngestionService, SyncStats } from './ingestion-service.js'; +import type { LogPathResolver } from './log-path-resolver.js'; + +export type ScheduledServer = { + serverId: string; + providerServerId: string; + /** Resolved on every sync so per-boot dated log folders are followed. */ + resolveLogPath: LogPathResolver; +}; + +const MAX_BACKOFF_MULTIPLIER = 8; + +/** + * Background polling loop. One timer per server, a per-server lock so syncs + * never overlap, exponential backoff after consecutive failures (to avoid + * hammering a broken Pterodactyl), and graceful shutdown that waits for + * in-flight syncs. + */ +export class IngestionScheduler { + private timers = new Map>(); + private inFlight = new Map>(); + private failureCounts = new Map(); + private stopped = false; + private lastResults = new Map(); + + constructor( + private readonly service: LogIngestionService, + private readonly logger: Logger, + private readonly intervalMs: number, + ) {} + + start(servers: ScheduledServer[]): void { + for (const server of servers) { + this.schedule(server, 1_000 + Math.floor(Math.random() * 2_000)); + } + this.logger.info( + { servers: servers.length, intervalSeconds: this.intervalMs / 1000 }, + 'log ingestion scheduler started', + ); + } + + /** Manually trigger a sync; shares the per-server lock with the poller. */ + async syncNow(server: ScheduledServer): Promise { + const existing = this.inFlight.get(server.serverId); + if (existing) { + await existing.catch(() => undefined); + } + let stats!: SyncStats; + const run = (async () => { + const logPath = await server.resolveLogPath(); + if (!logPath) { + throw ApiError.notConfigured( + 'Could not locate the current Reforger log file. Check REFORGER_LOG_DIRECTORY / REFORGER_ADMIN_LOG_PATH.', + ); + } + stats = await this.service.sync(server.serverId, server.providerServerId, logPath); + })(); + this.inFlight.set(server.serverId, run.catch(() => undefined) as Promise); + try { + await run; + } finally { + this.inFlight.delete(server.serverId); + } + this.lastResults.set(server.serverId, stats); + return stats; + } + + getLastResult(serverId: string): SyncStats | null { + return this.lastResults.get(serverId) ?? null; + } + + private schedule(server: ScheduledServer, delayMs: number): void { + if (this.stopped) return; + const timer = setTimeout(() => void this.tick(server), delayMs); + timer.unref?.(); + this.timers.set(server.serverId, timer); + } + + private async tick(server: ScheduledServer): Promise { + if (this.stopped) return; + if (this.inFlight.has(server.serverId)) { + this.schedule(server, this.intervalMs); + return; + } + const run = server + .resolveLogPath() + .then((logPath) => { + if (!logPath) { + throw new Error('no log path resolved'); + } + return this.service.sync(server.serverId, server.providerServerId, logPath); + }) + .then((stats) => { + this.lastResults.set(server.serverId, stats); + this.failureCounts.set(server.serverId, 0); + }) + .catch(() => { + const failures = (this.failureCounts.get(server.serverId) ?? 0) + 1; + this.failureCounts.set(server.serverId, failures); + }); + this.inFlight.set(server.serverId, run); + await run; + this.inFlight.delete(server.serverId); + + const failures = this.failureCounts.get(server.serverId) ?? 0; + const multiplier = Math.min(2 ** failures, MAX_BACKOFF_MULTIPLIER); + this.schedule(server, this.intervalMs * multiplier); + } + + /** Stop scheduling and wait for any in-flight sync to finish. */ + async stop(): Promise { + this.stopped = true; + for (const timer of this.timers.values()) clearTimeout(timer); + this.timers.clear(); + await Promise.allSettled(this.inFlight.values()); + this.logger.info('log ingestion scheduler stopped'); + } +} diff --git a/apps/api/src/modules/reforger-logs/ingestion/types.ts b/apps/api/src/modules/reforger-logs/ingestion/types.ts new file mode 100644 index 0000000..b252078 --- /dev/null +++ b/apps/api/src/modules/reforger-logs/ingestion/types.ts @@ -0,0 +1,88 @@ +import type { ServerEventType } from '@reforger-panel/shared'; +import type { DownloadableFile } from '../../pterodactyl/types.js'; + +export type CursorRecord = { + serverId: string; + logPath: string; + fileFingerprint: string | null; + lastByteOffset: number; + lastLineHash: string | null; + partialTrailingLine: string | null; + lastEventTimestamp: Date | null; + lastSuccessfulSyncAt: Date | null; + lastErrorAt: Date | null; + lastErrorMessage: string | null; +}; + +export type PlayerRecord = { + id: string; + serverId: string; + externalPlayerId: string | null; + displayName: string; +}; + +export type OpenSessionRecord = { + id: string; + playerId: string; + connectedAt: Date; +}; + +export type NewServerEvent = { + serverId: string; + eventType: ServerEventType; + occurredAt: Date; + playerId?: string | null; + playerSessionId?: string | null; + summary: string; + payload: Record; + sourceLogPath: string; + sourceLineHash: string; +}; + +/** + * Persistence boundary for log ingestion. Production uses Drizzle/Postgres; + * tests use an in-memory implementation. + */ +export interface IngestionStore { + getCursor(serverId: string, logPath: string): Promise; + saveCursor(cursor: CursorRecord): Promise; + + /** Returns created=false when the dedupe key already exists. */ + insertEventIfNew(event: NewServerEvent): Promise<{ created: boolean; eventId: string | null }>; + + findPlayerByExternalId(serverId: string, externalPlayerId: string): Promise; + findPlayerByName(serverId: string, displayName: string): Promise; + createPlayer(input: { + serverId: string; + displayName: string; + externalPlayerId: string | null; + seenAt: Date; + }): Promise; + updatePlayer( + playerId: string, + patch: { externalPlayerId?: string; displayName?: string; lastSeenAt?: Date }, + ): Promise; + + getOpenSession(serverId: string, playerId: string): Promise; + openSession(input: { + serverId: string; + playerId: string; + connectedAt: Date; + sourceLogPath: string; + }): Promise; + closeSession( + sessionId: string, + input: { disconnectedAt: Date; durationSeconds: number; disconnectReason: string | null }, + ): Promise; + /** Close every open session on the server (used when a fresh server start is seen). */ + closeAllOpenSessions( + serverId: string, + disconnectedAt: Date, + reason: string, + ): Promise<{ closed: number }>; +} + +/** Source of log file bytes; production wraps the Pterodactyl provider. */ +export interface LogSource { + fetchLog(serverId: string, logPath: string, maxBytes: number): Promise; +} diff --git a/apps/api/src/modules/reforger-logs/missions-catalog.test.ts b/apps/api/src/modules/reforger-logs/missions-catalog.test.ts new file mode 100644 index 0000000..5f06103 --- /dev/null +++ b/apps/api/src/modules/reforger-logs/missions-catalog.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { mergeMissions, parseMissionList, scenariosFromWorkshopMod } from './missions-catalog.js'; + +// Verbatim shape from a real console.log (server runs with -listScenarios). +const LOG = [ + '12:54:28.215 SCRIPT : --------------------------------------------------', + '12:54:28.215 SCRIPT : Official scenarios (3 entries)', + '12:54:28.216 SCRIPT : --------------------------------------------------', + '12:54:28.216 SCRIPT : {ECC61978EDCC2B5A}Missions/23_Campaign.conf (Conflict - Everon)', + '12:54:28.216 SCRIPT : {002AF7323E0129AF}Missions/Tutorial.conf (Training)', + '12:54:28.217 SCRIPT : {59AD59368755F41A}Missions/21_GM_Eden.conf (Game Master - Everon)', + '12:54:29.000 SCRIPT : Workshop scenarios (1 entries)', + '12:54:29.001 SCRIPT : {ABCDEF0123456789}Missions/CustomOps.conf (Custom Ops)', + '12:54:30.000 DEFAULT : something unrelated', +].join('\n'); + +describe('parseMissionList', () => { + it('parses scenario ids, display names, and section sources', () => { + const missions = parseMissionList(LOG); + expect(missions).toHaveLength(4); + expect(missions[0]).toEqual({ + scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf', + name: 'Conflict - Everon', + source: 'official', + }); + expect(missions[3]).toEqual({ + scenarioId: '{ABCDEF0123456789}Missions/CustomOps.conf', + name: 'Custom Ops', + source: 'workshop', + }); + }); + + it('deduplicates repeated listings (multiple boots in one file)', () => { + const missions = parseMissionList(`${LOG}\n${LOG}`); + expect(missions).toHaveLength(4); + }); + + it('returns an empty list when no listing is present', () => { + expect(parseMissionList('12:00:00.000 DEFAULT : nothing here')).toEqual([]); + }); +}); + +describe('workshop scenario helpers', () => { + it('converts mod scenarios into mission entries', () => { + const missions = scenariosFromWorkshopMod({ + id: 'ABC', + name: 'Scenario Pack', + author: 'Author', + imageUrl: null, + size: null, + rating: null, + workshopUrl: null, + version: null, + gameVersion: null, + subscribers: null, + downloads: null, + createdAtText: null, + lastModifiedText: null, + summary: null, + description: null, + license: null, + tags: [], + dependencies: [], + scenarios: [ + { + name: 'Raid Night', + description: null, + scenarioId: '{1111111111111111}Missions/RaidNight.conf', + gamemode: 'Coop', + playerCount: 32, + imageUrl: null, + }, + ], + }); + expect(missions).toEqual([ + { + scenarioId: '{1111111111111111}Missions/RaidNight.conf', + name: 'Raid Night', + source: 'mod: Scenario Pack', + }, + ]); + }); + + it('deduplicates mission groups while preserving first source', () => { + const merged = mergeMissions( + [{ scenarioId: 'same', name: 'From Log', source: 'workshop' }], + [{ scenarioId: 'same', name: 'From Mod', source: 'mod: Pack' }], + [{ scenarioId: 'other', name: 'Other', source: 'mod: Pack' }], + ); + expect(merged).toEqual([ + { scenarioId: 'same', name: 'From Log', source: 'workshop' }, + { scenarioId: 'other', name: 'Other', source: 'mod: Pack' }, + ]); + }); +}); diff --git a/apps/api/src/modules/reforger-logs/missions-catalog.ts b/apps/api/src/modules/reforger-logs/missions-catalog.ts new file mode 100644 index 0000000..f51dcce --- /dev/null +++ b/apps/api/src/modules/reforger-logs/missions-catalog.ts @@ -0,0 +1,102 @@ +import type { MissionInfo, MissionsResponse, WorkshopModDetail } from '@reforger-panel/shared'; +import type { GameServerProvider } from '../pterodactyl/types.js'; +import type { LogPathResolver } from './ingestion/log-path-resolver.js'; + +const CATALOG_TTL_MS = 10 * 60 * 1000; +const CATALOG_MAX_BYTES = 2 * 1024 * 1024; + +/** + * Scenario listing printed at boot when the server runs with -listScenarios + * (verified against real logs): + * 12:54:28.215 SCRIPT : Official scenarios (31 entries) + * 12:54:28.216 SCRIPT : {ECC61978EDCC2B5A}Missions/23_Campaign.conf (Conflict - Everon) + */ +const SECTION_PATTERN = /SCRIPT\s*:\s*(.+ scenarios) \(\d+ entr/i; +const MISSION_PATTERN = /SCRIPT\s*:\s*(\{[0-9A-Fa-f]{16}\}\S+\.conf)(?:\s+\((.+)\))?\s*$/; + +export function parseMissionList(logContent: string): MissionInfo[] { + const missions: MissionInfo[] = []; + const seen = new Set(); + let currentSource = 'official'; + for (const line of logContent.split('\n')) { + const section = SECTION_PATTERN.exec(line); + if (section) { + currentSource = section[1]!.toLowerCase().replace(/ scenarios$/, ''); + continue; + } + const mission = MISSION_PATTERN.exec(line); + if (mission && !seen.has(mission[1]!)) { + seen.add(mission[1]!); + missions.push({ + scenarioId: mission[1]!, + name: mission[2] ?? mission[1]!.slice(mission[1]!.lastIndexOf('/') + 1), + source: currentSource, + }); + } + } + return missions; +} + +export function scenariosFromWorkshopMod(mod: WorkshopModDetail): MissionInfo[] { + return mod.scenarios.map((scenario) => ({ + scenarioId: scenario.scenarioId, + name: scenario.name, + source: `mod: ${mod.name}`, + })); +} + +export function mergeMissions(...groups: MissionInfo[][]): MissionInfo[] { + const merged: MissionInfo[] = []; + const seen = new Set(); + for (const group of groups) { + for (const mission of group) { + if (seen.has(mission.scenarioId)) continue; + seen.add(mission.scenarioId); + merged.push(mission); + } + } + return merged; +} + +/** + * Extracts the available-missions dropdown data from the server's current + * console.log. Cached briefly; a fresh boot log always carries the listing + * near the top, so the head of the file is enough. + */ +export class MissionCatalog { + private cache: { missions: MissionInfo[]; fetchedAt: string; expiresAt: number } | null = null; + + constructor( + private readonly provider: GameServerProvider, + private readonly resolveLogPath: LogPathResolver, + private readonly providerServerId: string, + ) {} + + async list(force = false): Promise { + if (!force && this.cache && this.cache.expiresAt > Date.now()) { + return { missions: this.cache.missions, fetchedAt: this.cache.fetchedAt }; + } + const logPath = await this.resolveLogPath(); + if (!logPath) return { missions: [], fetchedAt: null }; + const file = await this.provider.downloadTextFile( + this.providerServerId, + logPath, + CATALOG_MAX_BYTES, + ); + const missions = parseMissionList(file.content); + if (missions.length > 0) { + this.cache = { + missions, + fetchedAt: new Date().toISOString(), + expiresAt: Date.now() + CATALOG_TTL_MS, + }; + return { missions, fetchedAt: this.cache.fetchedAt }; + } + // Long-running servers may have rotated past the listing; keep the last + // known catalog rather than returning nothing. + if (this.cache) { + return { missions: this.cache.missions, fetchedAt: this.cache.fetchedAt }; + } + return { missions: [], fetchedAt: null }; + } +} diff --git a/apps/api/src/modules/reforger-logs/parser/parser.test.ts b/apps/api/src/modules/reforger-logs/parser/parser.test.ts new file mode 100644 index 0000000..bbf11f8 --- /dev/null +++ b/apps/api/src/modules/reforger-logs/parser/parser.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest'; +import { parseLogChunk } from './parser.js'; + +const HEADER = 'Log started 2026-07-04 10:00:00'; + +function line(time: string, category: string, message: string): string { + return `${time} ${category.padEnd(12)} : ${message}`; +} + +const CONNECT_LINE = line( + '10:05:01.123', + 'DEFAULT', + "BattlEye Server: 'Player #1 Braeden (10.0.0.2:50241) connected'", +); +const GUID_LINE = line( + '10:05:02.500', + 'DEFAULT', + "BattlEye Server: 'Player #1 Braeden - GUID: 9f2ab04c11d9e0aa'", +); +const DISCONNECT_LINE = line( + '10:45:09.001', + 'DEFAULT', + "BattlEye Server: 'Player #1 Braeden disconnected'", +); + +describe('parseLogChunk', () => { + it('parses a player connect event with timestamp from the header date', () => { + const result = parseLogChunk(`${HEADER}\n${CONNECT_LINE}\n`); + expect(result.events).toHaveLength(1); + const event = result.events[0]!; + expect(event.type).toBe('player_connected'); + if (event.type === 'player_connected') { + expect(event.playerName).toBe('Braeden'); + expect(event.playerNumber).toBe(1); + expect(event.occurredAt.toISOString()).toBe('2026-07-04T10:05:01.123Z'); + } + }); + + it('parses disconnect events and identity (GUID) lines', () => { + const result = parseLogChunk(`${HEADER}\n${CONNECT_LINE}\n${GUID_LINE}\n${DISCONNECT_LINE}\n`); + expect(result.events.map((e) => e.type)).toEqual([ + 'player_connected', + 'player_identity', + 'player_disconnected', + ]); + const identity = result.events[1]!; + if (identity.type === 'player_identity') { + expect(identity.externalPlayerId).toBe('9f2ab04c11d9e0aa'); + } + }); + + it('parses player names containing spaces and parentheses-free IPs', () => { + const weird = line( + '10:06:00.000', + 'DEFAULT', + "BattlEye Server: 'Player #7 Sgt. Moss Jr (192.168.1.44:61022) connected'", + ); + const result = parseLogChunk(`${HEADER}\n${weird}\n`); + expect(result.events).toHaveLength(1); + if (result.events[0]!.type === 'player_connected') { + expect(result.events[0]!.playerName).toBe('Sgt. Moss Jr'); + } + }); + + it('parses engine-level authenticated-player identity lines (real log format)', () => { + const backend = line( + '12:57:48.941', + 'BACKEND', + 'Authenticated player: rplIdentity=0x00000000 identityId=33cd5666-3466-477c-aeb8-010df1978756 name=mcdazzzled', + ); + const result = parseLogChunk(`${HEADER}\n${backend}\n`); + expect(result.events).toHaveLength(1); + const event = result.events[0]!; + expect(event.type).toBe('player_identity'); + if (event.type === 'player_identity') { + expect(event.playerName).toBe('mcdazzzled'); + expect(event.externalPlayerId).toBe('33cd5666-3466-477c-aeb8-010df1978756'); + } + }); + + it('detects server start lines', () => { + const content = `${HEADER}\n${line('10:00:05.000', 'DEFAULT', 'Game successfully created.')}\n`; + const result = parseLogChunk(content); + expect(result.events).toHaveLength(1); + expect(result.events[0]!.type).toBe('server_started'); + }); + + it('parses ServerAdminTools killfeed lines', () => { + const kill = line( + '10:12:30.000', + 'SCRIPT', + 'ServerAdminTools | Event serveradmintools_player_killed | player: Victim, instigator: Killer, friendly: false', + ); + const result = parseLogChunk(`${HEADER}\n${kill}\n`); + expect(result.events).toHaveLength(1); + const event = result.events[0]!; + expect(event.type).toBe('player_killed'); + if (event.type === 'player_killed') { + expect(event.victimName).toBe('Victim'); + expect(event.killerName).toBe('Killer'); + expect(event.friendly).toBe(false); + } + }); + + it('handles multiple simultaneous players', () => { + const content = [ + HEADER, + line('10:01:00.000', 'DEFAULT', "BattlEye Server: 'Player #1 Alpha (10.0.0.1:1) connected'"), + line('10:01:01.000', 'DEFAULT', "BattlEye Server: 'Player #2 Bravo (10.0.0.2:2) connected'"), + line( + '10:01:02.000', + 'DEFAULT', + "BattlEye Server: 'Player #3 Charlie (10.0.0.3:3) connected'", + ), + line('10:30:00.000', 'DEFAULT', "BattlEye Server: 'Player #2 Bravo disconnected'"), + '', + ].join('\n'); + const result = parseLogChunk(content); + expect(result.events).toHaveLength(4); + expect(result.events.filter((e) => e.type === 'player_connected')).toHaveLength(3); + }); + + it('ignores unknown lines safely and counts them', () => { + const content = [ + HEADER, + line('10:02:00.000', 'SCRIPT', 'SCR_BaseGameMode: match state changed'), + line('10:02:01.000', 'NETWORK', '### Connection stats'), + 'complete garbage that matches nothing', + CONNECT_LINE, + '', + ].join('\n'); + const result = parseLogChunk(content); + expect(result.events).toHaveLength(1); + expect(result.ignoredLineCount).toBe(3); + }); + + it('rejects invalid timestamps without crashing', () => { + const content = `${HEADER}\n${line('25:99:99.000', 'DEFAULT', 'Game successfully created.')}\n${CONNECT_LINE}\n`; + const result = parseLogChunk(content); + expect(result.invalidTimestampCount).toBe(1); + expect(result.events).toHaveLength(1); + }); + + it('returns the partial trailing line unparsed', () => { + const partial = "10:50:00.100 DEFAULT : BattlEye Server: 'Player #2 Sab"; + const result = parseLogChunk(`${HEADER}\n${CONNECT_LINE}\n${partial}`); + expect(result.events).toHaveLength(1); + expect(result.partialTrailingLine).toBe(partial); + }); + + it('rolls the date over at midnight', () => { + const content = [ + 'Log started 2026-07-04 23:58:00', + line('23:59:30.000', 'DEFAULT', "BattlEye Server: 'Player #1 Alpha (10.0.0.1:1) connected'"), + line('00:01:10.000', 'DEFAULT', "BattlEye Server: 'Player #1 Alpha disconnected'"), + '', + ].join('\n'); + const result = parseLogChunk(content); + expect(result.events).toHaveLength(2); + expect(result.events[0]!.occurredAt.toISOString()).toBe('2026-07-04T23:59:30.000Z'); + expect(result.events[1]!.occurredAt.toISOString()).toBe('2026-07-05T00:01:10.000Z'); + }); + + it('uses the fallback date when no header is present, without producing future timestamps', () => { + const fallback = new Date('2026-07-05T00:10:00.000Z'); + const result = parseLogChunk( + `${line('23:55:00.000', 'DEFAULT', 'Game successfully created.')}\n`, + { + fallbackDate: fallback, + }, + ); + expect(result.events).toHaveLength(1); + expect(result.events[0]!.occurredAt.toISOString()).toBe('2026-07-04T23:55:00.000Z'); + }); + + it('parses an empty chunk without events', () => { + const result = parseLogChunk(''); + expect(result.events).toHaveLength(0); + expect(result.completeLineCount).toBe(0); + expect(result.partialTrailingLine).toBeNull(); + }); +}); diff --git a/apps/api/src/modules/reforger-logs/parser/parser.ts b/apps/api/src/modules/reforger-logs/parser/parser.ts new file mode 100644 index 0000000..ed58436 --- /dev/null +++ b/apps/api/src/modules/reforger-logs/parser/parser.ts @@ -0,0 +1,217 @@ +import { + AUTHENTICATED_PLAYER_PATTERN, + BATTLEYE_WRAPPER_PATTERN, + LINE_PREFIX_PATTERN, + LOG_HEADER_PATTERN, + PLAYER_CONNECTED_PATTERN, + PLAYER_DISCONNECTED_PATTERN, + PLAYER_GUID_PATTERN, + SERVER_ADMIN_TOOLS_KILL_PATTERN, + SERVER_STARTED_PATTERNS, +} from './patterns.js'; +import type { ParseChunkResult, ParsedLogEvent, ParserContext } from './types.js'; + +const MIDNIGHT_ROLLOVER_TOLERANCE_MS = 60_000; + +export function emptyContext(): ParserContext { + return { baseDate: null, lastTimestamp: null }; +} + +function startOfDayUtc(date: Date): Date { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + +/** + * Combine a time-of-day stamp with the tracked calendar date. Reforger's + * console.log lines carry no date, so the date comes from the log header when + * present, otherwise from the fallback (file time / now). Rollover past + * midnight is detected by the clock going backwards. + */ +function resolveTimestamp( + hours: number, + minutes: number, + seconds: number, + millis: number, + context: ParserContext, + fallbackDate: Date, +): Date | null { + if (hours > 23 || minutes > 59 || seconds > 59) return null; + const base = context.baseDate ?? startOfDayUtc(fallbackDate); + if (!context.baseDate) context.baseDate = base; + + let timestamp = new Date( + base.getTime() + ((hours * 60 + minutes) * 60 + seconds) * 1000 + millis, + ); + // No header date + fallback of "today" can push pre-midnight lines into the + // future when syncing just after midnight; pull them back a day. + if ( + !context.lastTimestamp && + timestamp.getTime() > fallbackDate.getTime() + MIDNIGHT_ROLLOVER_TOLERANCE_MS + ) { + context.baseDate = new Date(base.getTime() - 24 * 60 * 60 * 1000); + timestamp = new Date(timestamp.getTime() - 24 * 60 * 60 * 1000); + } + if ( + context.lastTimestamp && + timestamp.getTime() < context.lastTimestamp.getTime() - MIDNIGHT_ROLLOVER_TOLERANCE_MS + ) { + context.baseDate = new Date(base.getTime() + 24 * 60 * 60 * 1000); + timestamp = new Date(timestamp.getTime() + 24 * 60 * 60 * 1000); + } + context.lastTimestamp = timestamp; + return timestamp; +} + +function parseMessage(message: string, occurredAt: Date, rawLine: string): ParsedLogEvent | null { + const battleye = BATTLEYE_WRAPPER_PATTERN.exec(message); + const body = battleye ? battleye[1]! : message; + + const connected = PLAYER_CONNECTED_PATTERN.exec(body); + if (connected) { + return { + type: 'player_connected', + occurredAt, + playerNumber: Number(connected[1]), + playerName: connected[2]!, + rawLine, + }; + } + + const authenticated = AUTHENTICATED_PLAYER_PATTERN.exec(body); + if (authenticated) { + return { + type: 'player_identity', + occurredAt, + playerName: authenticated[2]!, + externalPlayerId: authenticated[1]!.toLowerCase(), + rawLine, + }; + } + + const guid = PLAYER_GUID_PATTERN.exec(body); + if (guid) { + return { + type: 'player_identity', + occurredAt, + playerNumber: Number(guid[1]), + playerName: guid[2]!, + externalPlayerId: guid[3]!.toLowerCase(), + rawLine, + }; + } + + const disconnected = PLAYER_DISCONNECTED_PATTERN.exec(body); + if (disconnected) { + return { + type: 'player_disconnected', + occurredAt, + playerNumber: Number(disconnected[1]), + playerName: disconnected[2]!, + reason: disconnected[3] || undefined, + rawLine, + }; + } + + if (SERVER_STARTED_PATTERNS.some((pattern) => pattern.test(body))) { + return { type: 'server_started', occurredAt, rawLine }; + } + + const kill = SERVER_ADMIN_TOOLS_KILL_PATTERN.exec(body); + if (kill) { + return { + type: 'player_killed', + occurredAt, + victimName: kill[1]!, + killerName: kill[2]!, + friendly: kill[3]!.toLowerCase() === 'true', + rawLine, + }; + } + + return null; +} + +/** + * Parse a chunk of log content. The chunk must start at a line boundary + * (callers prepend any stored partial trailing line). Pure and side-effect + * free apart from the returned, updated context. + */ +export function parseLogChunk( + content: string, + options: { context?: ParserContext; fallbackDate?: Date } = {}, +): ParseChunkResult { + const context: ParserContext = options.context ? { ...options.context } : emptyContext(); + const fallbackDate = options.fallbackDate ?? new Date(); + + const endsWithNewline = content.endsWith('\n'); + const segments = content.split('\n'); + const partialTrailingLine = endsWithNewline ? null : (segments.pop() ?? null); + if (endsWithNewline) segments.pop(); // drop the empty segment after the final newline + + const events: ParsedLogEvent[] = []; + let ignoredLineCount = 0; + let invalidTimestampCount = 0; + let lastCompleteLine: string | null = null; + + for (const rawSegment of segments) { + const line = rawSegment.replace(/\r$/, ''); + lastCompleteLine = line; + if (line.trim() === '') continue; + + const header = LOG_HEADER_PATTERN.exec(line); + if (header) { + const [, year, month, day, hours, minutes, seconds] = header; + const headerDate = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), 0, 0, 0)); + if (!Number.isNaN(headerDate.getTime())) { + context.baseDate = headerDate; + context.lastTimestamp = new Date( + Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hours), + Number(minutes), + Number(seconds), + ), + ); + } + continue; + } + + const prefix = LINE_PREFIX_PATTERN.exec(line); + if (!prefix) { + ignoredLineCount += 1; + continue; + } + const [, h, m, s, ms, , message] = prefix; + const occurredAt = resolveTimestamp( + Number(h), + Number(m), + Number(s), + Number(ms), + context, + fallbackDate, + ); + if (!occurredAt) { + invalidTimestampCount += 1; + continue; + } + + const event = parseMessage(message!, occurredAt, line); + if (event) { + events.push(event); + } else { + ignoredLineCount += 1; + } + } + + return { + events, + completeLineCount: segments.length, + ignoredLineCount, + invalidTimestampCount, + partialTrailingLine: partialTrailingLine === '' ? null : partialTrailingLine, + lastCompleteLine, + context, + }; +} diff --git a/apps/api/src/modules/reforger-logs/parser/patterns.ts b/apps/api/src/modules/reforger-logs/parser/patterns.ts new file mode 100644 index 0000000..79e664f --- /dev/null +++ b/apps/api/src/modules/reforger-logs/parser/patterns.ts @@ -0,0 +1,57 @@ +/** + * Line patterns for Arma Reforger (Enfusion) server logs. + * + * These are based on observed community-documented output and the bundled + * fixtures, NOT an official spec — Bohemia can change them between game + * versions. All pattern knowledge lives in this file so new formats only + * require touching the regexes below and adding a fixture. Unknown lines are + * ignored safely and only counted in diagnostics. + * + * Canonical shapes targeted: + * Log started 2026-07-04 11:22:33 + * 11:24:01.001 DEFAULT : BattlEye Server: 'Player #1 Braeden (10.0.0.2:50241) connected' + * 11:24:03.500 DEFAULT : BattlEye Server: 'Player #1 Braeden - GUID: 9f2ab04c11d9e0aa' + * 11:52:09.114 DEFAULT : BattlEye Server: 'Player #1 Braeden disconnected' + * 11:22:35.123 DEFAULT : Game successfully created. + */ + +/** Header written at the top of console.log; provides the calendar date. */ +export const LOG_HEADER_PATTERN = + /^Log started\s+(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/; + +/** Enfusion line prefix: time-of-day, category, colon, message. */ +export const LINE_PREFIX_PATTERN = /^(\d{1,2}):(\d{2}):(\d{2})\.(\d{3})\s+([A-Z]+)\s*:\s*(.*)$/; + +/** BattlEye messages are quoted inside a wrapper on the DEFAULT channel. */ +export const BATTLEYE_WRAPPER_PATTERN = /^BattlEye Server: '(.*)'$/; + +/** Player #1 Name (1.2.3.4:56789) connected */ +export const PLAYER_CONNECTED_PATTERN = + /^Player #(\d+) (.+) \((?:\d{1,3}\.){3}\d{1,3}:\d+\) connected$/; + +/** Player #1 Name disconnected (optionally with a trailing reason in parentheses) */ +export const PLAYER_DISCONNECTED_PATTERN = /^Player #(\d+) (.+?) disconnected(?: \((.+)\))?$/; + +/** Player #1 Name - GUID: abcdef0123456789 (also matches "- BE GUID:") */ +export const PLAYER_GUID_PATTERN = /^Player #(\d+) (.+) - (?:BE )?GUID: ([0-9a-fA-F]{8,64})$/; + +/** + * Engine-level identity on the BACKEND channel (verified against real logs): + * Authenticated player: rplIdentity=0x00000000 identityId= name= + * Available even when BattlEye is disabled. + */ +export const AUTHENTICATED_PLAYER_PATTERN = + /^Authenticated player: .*identityId=([0-9a-fA-F-]{8,64}) name=(.+)$/; + +/** Messages that indicate the server process finished starting a session. */ +export const SERVER_STARTED_PATTERNS: RegExp[] = [ + /^Game successfully created\.?$/, + /^Server is ready to accept connections/, +]; + +/** + * ServerAdminTools killfeed line observed in reforger-stats: + * ServerAdminTools | Event serveradmintools_player_killed | player: Victim, instigator: Killer, friendly: false + */ +export const SERVER_ADMIN_TOOLS_KILL_PATTERN = + /^ServerAdminTools \| Event serveradmintools_player_killed \| player: (.+), instigator: (.+), friendly: (true|false)$/i; diff --git a/apps/api/src/modules/reforger-logs/parser/types.ts b/apps/api/src/modules/reforger-logs/parser/types.ts new file mode 100644 index 0000000..4ff50b2 --- /dev/null +++ b/apps/api/src/modules/reforger-logs/parser/types.ts @@ -0,0 +1,63 @@ +export type ParsedLogEvent = + | { + type: 'player_connected'; + occurredAt: Date; + playerName: string; + playerNumber?: number; + externalPlayerId?: string; + rawLine: string; + } + | { + type: 'player_disconnected'; + occurredAt: Date; + playerName: string; + playerNumber?: number; + externalPlayerId?: string; + reason?: string; + rawLine: string; + } + | { + /** + * Identity lines (e.g. BattlEye GUID) arrive separately from connects; + * ingestion merges them into the matching player record. + */ + type: 'player_identity'; + occurredAt: Date; + playerName: string; + playerNumber?: number; + externalPlayerId: string; + rawLine: string; + } + | { + type: 'server_started'; + occurredAt: Date; + rawLine: string; + } + | { + type: 'player_killed'; + occurredAt: Date; + victimName: string; + killerName: string; + friendly: boolean; + rawLine: string; + }; + +export type ParserContext = { + /** Calendar date the time-of-day stamps are relative to (from the log header). */ + baseDate: Date | null; + /** Last timestamp emitted; used to detect midnight rollover. */ + lastTimestamp: Date | null; +}; + +export type ParseChunkResult = { + events: ParsedLogEvent[]; + completeLineCount: number; + /** Lines that matched no pattern. Safe to ignore; counted for diagnostics only. */ + ignoredLineCount: number; + invalidTimestampCount: number; + /** Content after the final newline — not parsed, carried to the next sync. */ + partialTrailingLine: string | null; + /** Raw text of the last complete line, for cursor continuity checks. */ + lastCompleteLine: string | null; + context: ParserContext; +}; diff --git a/apps/api/src/modules/servers/resource-history.ts b/apps/api/src/modules/servers/resource-history.ts new file mode 100644 index 0000000..70138f2 --- /dev/null +++ b/apps/api/src/modules/servers/resource-history.ts @@ -0,0 +1,103 @@ +import type { ResourceHistoryResponse, ResourceSample } from '@reforger-panel/shared'; +import type { Logger } from '../../lib/logger.js'; +import type { GameServerProvider } from '../pterodactyl/types.js'; + +export const SAMPLE_INTERVAL_SECONDS = 15; +const MAX_SAMPLES = 240; // ~1 hour window + +type RawSample = ResourceSample & { rxTotal: number; txTotal: number }; + +/** + * In-memory rolling window of resource usage for the dashboard graphs. + * Network rates are derived from the provider's cumulative rx/tx counters; + * history is intentionally not persisted (it is telemetry, not records). + */ +export class ResourceHistoryService { + private samples = new Map(); + private timer: ReturnType | null = null; + private servers: { serverId: string; providerServerId: string }[] = []; + + constructor( + private readonly provider: GameServerProvider, + private readonly logger: Logger, + private readonly intervalSeconds: number = SAMPLE_INTERVAL_SECONDS, + ) {} + + start(servers: { serverId: string; providerServerId: string }[]): void { + this.servers = servers; + void this.sampleAll(); + this.timer = setInterval(() => void this.sampleAll(), this.intervalSeconds * 1000); + this.timer.unref?.(); + this.logger.info({ servers: servers.length }, 'resource history sampler started'); + } + + stop(): void { + if (this.timer) clearInterval(this.timer); + this.timer = null; + } + + private async sampleAll(): Promise { + for (const server of this.servers) { + try { + await this.sampleOne(server.serverId, server.providerServerId); + } catch { + // Provider unreachable: record an offline-ish gap sample so graphs + // show the outage instead of freezing on the last good value. + this.push(server.serverId, { + t: Date.now(), + status: 'unknown', + cpuPercent: 0, + cpuLimitPercent: null, + memoryBytes: 0, + memoryLimitBytes: null, + networkRxRate: 0, + networkTxRate: 0, + rxTotal: -1, + txTotal: -1, + }); + } + } + } + + private async sampleOne(serverId: string, providerServerId: string): Promise { + const resources = await this.provider.getServerResources(providerServerId); + const previous = this.samples.get(serverId)?.at(-1); + const now = Date.now(); + + let networkRxRate = 0; + let networkTxRate = 0; + if (previous && previous.rxTotal >= 0 && now > previous.t) { + const dtSeconds = (now - previous.t) / 1000; + // Counters reset on server restart; clamp negative deltas to zero. + networkRxRate = Math.max(0, (resources.networkRxBytes - previous.rxTotal) / dtSeconds); + networkTxRate = Math.max(0, (resources.networkTxBytes - previous.txTotal) / dtSeconds); + } + + this.push(serverId, { + t: now, + status: resources.status, + cpuPercent: Math.round(resources.cpuPercent * 10) / 10, + cpuLimitPercent: resources.cpuLimitPercent, + memoryBytes: resources.memoryBytes, + memoryLimitBytes: resources.memoryLimitBytes, + networkRxRate: Math.round(networkRxRate), + networkTxRate: Math.round(networkTxRate), + rxTotal: resources.networkRxBytes, + txTotal: resources.networkTxBytes, + }); + } + + private push(serverId: string, sample: RawSample): void { + const list = this.samples.get(serverId) ?? []; + list.push(sample); + if (list.length > MAX_SAMPLES) list.splice(0, list.length - MAX_SAMPLES); + this.samples.set(serverId, list); + } + + history(serverId: string): ResourceHistoryResponse { + const samples = (this.samples.get(serverId) ?? []).map( + ({ rxTotal: _rx, txTotal: _tx, ...sample }) => sample, + ); + return { samples, intervalSeconds: this.intervalSeconds }; + } +} diff --git a/apps/api/src/modules/servers/server-routes.ts b/apps/api/src/modules/servers/server-routes.ts new file mode 100644 index 0000000..77090ae --- /dev/null +++ b/apps/api/src/modules/servers/server-routes.ts @@ -0,0 +1,687 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import type { + LogIngestionHealth, + ServerResources, + ServerStatus, + ServerSummary, +} from '@reforger-panel/shared'; +import { ApiError } from '../../lib/errors.js'; +import { rateLimit } from '../../lib/rate-limit.js'; +import { requireAuth, requireCapability } from '../auth/auth-middleware.js'; +import type { ConfigSyncService } from '../config/config-sync.js'; +import type { ServerModsService } from '../config/mods-service.js'; +import type { PerformanceSettingsService } from '../config/performance-service.js'; +import type { ResourceHistoryService } from './resource-history.js'; +import type { GameServerProvider } from '../pterodactyl/types.js'; +import type { LogPathResolver } from '../reforger-logs/ingestion/log-path-resolver.js'; +import type { IngestionScheduler, ScheduledServer } from '../reforger-logs/ingestion/scheduler.js'; +import type { MissionCatalog } from '../reforger-logs/missions-catalog.js'; +import { mergeMissions, scenariosFromWorkshopMod } from '../reforger-logs/missions-catalog.js'; +import type { ServerRecord, ServerService } from './server-service.js'; +import type { WorkshopClient } from '../workshop/workshop-client.js'; + +const slugSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/, 'Invalid server slug.'); + +export type ServerRouterDeps = { + service: ServerService; + provider: GameServerProvider; + scheduler: IngestionScheduler | null; + resolveLogPath: LogPathResolver | null; + configSync: ConfigSyncService | null; + mods: ServerModsService | null; + performance: PerformanceSettingsService | null; + resourceHistory: ResourceHistoryService | null; + missions: MissionCatalog | null; + workshop: WorkshopClient; + staleAfterSeconds: number; + mockMode: boolean; +}; + +// Validation ranges follow the Bohemia server-config reference. Only provided +// keys are touched; `null` removes the key (the game default applies). +const performanceBodySchema = z + .object({ + scenarioId: z + .string() + .trim() + .max(200) + .regex(/^\{[0-9A-Fa-f]{16}\}\S+\.conf$/, 'Invalid scenario id.') + .nullable(), + maxPlayers: z.number().int().min(1).max(128).nullable(), + serverMaxViewDistance: z.number().int().min(500).max(10000).nullable(), + networkViewDistance: z.number().int().min(500).max(5000).nullable(), + serverMinGrassDistance: z.number().int().min(0).max(150).nullable(), + disableThirdPerson: z.boolean().nullable(), + fastValidation: z.boolean().nullable(), + battlEye: z.boolean().nullable(), + aiLimit: z.number().int().min(-1).max(1000).nullable(), + playerSaveTime: z.number().int().min(1).max(3600).nullable(), + slotReservationTimeout: z.number().int().min(5).max(300).nullable(), + lobbyPlayerSynchronise: z.boolean().nullable(), + }) + .partial() + .strict(); + +const startupVariableBodySchema = z.object({ + key: z.string().regex(/^[A-Z0-9_]{1,64}$/, 'Invalid variable name.'), + value: z.string().max(500), +}); + +const restartScheduleBodySchema = z.object({ + name: z.string().trim().min(1).max(100), + isActive: z.boolean(), + minute: z.number().int().min(0).max(59), + hour: z.number().int().min(0).max(23), + dayOfWeek: z.enum(['*', '0', '1', '2', '3', '4', '5', '6']), + onlyWhenOnline: z.boolean(), +}); + +const scheduleIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, 'Invalid schedule id.'); + +// Reforger Workshop mod IDs are 16 hex characters (see the Bohemia server +// config reference); name/version are free-ish text with sane caps. +const modsBodySchema = z.object({ + mods: z + .array( + z.object({ + modId: z.string().regex(/^[A-Fa-f0-9]{16}$/, 'Invalid mod id.'), + name: z.string().trim().max(200).optional(), + version: z + .string() + .trim() + .max(32) + .regex(/^[\w.+-]*$/, 'Invalid version.') + .optional(), + }), + ) + .max(200), +}); + +function providerId(server: ServerRecord): string { + return server.pterodactylServerId ?? server.slug; +} + +export function createServerRouter(deps: ServerRouterDeps): Router { + const router = Router(); + const { service, provider } = deps; + const powerRateLimit = rateLimit({ windowMs: 60_000, max: 10, keyPrefix: 'power' }); + const syncRateLimit = rateLimit({ windowMs: 60_000, max: 6, keyPrefix: 'logsync' }); + + router.use(requireAuth); + + async function loadServer(slugRaw: unknown): Promise { + const slug = slugSchema.safeParse(slugRaw); + if (!slug.success) throw ApiError.validation('Invalid server slug.'); + const server = await service.getServerBySlug(slug.data); + if (!server) throw ApiError.notFound('Server not found.'); + return server; + } + + async function toSummary(server: ServerRecord): Promise { + let status = server.status as ServerStatus; + try { + status = await provider.getServerStatus(providerId(server)); + if (status !== server.status) { + await service.updateStatus(server.id, status); + } + } catch { + // Provider unreachable: fall back to the last stored status. + } + return { + id: server.id, + slug: server.slug, + name: server.name, + providerType: server.providerType, + status, + maxPlayers: server.maxPlayers, + onlinePlayerCount: await service.countOnlinePlayers(server.id), + createdAt: server.createdAt.toISOString(), + updatedAt: server.updatedAt.toISOString(), + }; + } + + router.get('/', async (_req, res, next) => { + try { + const servers = await service.listServers(); + res.json({ servers: await Promise.all(servers.map((s) => toSummary(s))) }); + } catch (error) { + next(error); + } + }); + + router.get('/:slug', async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + res.json(await toSummary(server)); + } catch (error) { + next(error); + } + }); + + router.get('/:slug/resources', async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + const resources = await provider.getServerResources(providerId(server)); + const body: ServerResources = { ...resources, fetchedAt: new Date().toISOString() }; + res.json(body); + } catch (error) { + next(error); + } + }); + + router.get('/:slug/resources/history', async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + if (!deps.resourceHistory) { + throw ApiError.notConfigured('Resource history requires a configured game server backend.'); + } + res.json(deps.resourceHistory.history(server.id)); + } catch (error) { + next(error); + } + }); + + router.get('/:slug/config/performance', async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + if (!deps.performance) { + throw ApiError.notConfigured('Config editing requires a configured game server backend.'); + } + res.json(await deps.performance.get(server)); + } catch (error) { + next(error); + } + }); + + router.put( + '/:slug/config/performance', + syncRateLimit, + requireCapability('config.edit', 'You do not have permission to edit the configuration.'), + async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + if (!deps.performance) { + throw ApiError.notConfigured('Config editing requires a configured game server backend.'); + } + const body = performanceBodySchema.safeParse(req.body); + if (!body.success) { + const issue = body.error.issues[0]; + throw ApiError.validation( + issue ? `${issue.path.join('.')}: ${issue.message}` : 'Invalid settings.', + ); + } + const result = await deps.performance.update(server, body.data); + // Many Reforger eggs template config.json from startup variables at + // boot; mirror the mission there too so switching sticks either way. + if (result.changedFields.includes('scenarioId') && body.data.scenarioId) { + await provider + .updateStartupVariable(providerId(server), 'SCENARIO_ID', body.data.scenarioId) + .catch(() => undefined); // variable may not exist on this egg + } + if (result.changedFields.length > 0) { + const user = req.user!; + await service.recordActivity({ + serverId: server.id, + actorUserId: user.id, + action: 'config.performance.updated', + summary: `Performance settings updated by ${user.displayName ?? user.username}: ${result.changedFields.join(', ')} (applies on restart)`, + metadata: { changedFields: result.changedFields }, + }); + } + res.json(result); + } catch (error) { + next(error); + } + }, + ); + + router.get('/:slug/players', async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + res.json(await service.getOnlinePlayers(server, deps.staleAfterSeconds)); + } catch (error) { + next(error); + } + }); + + router.get('/:slug/players/known', async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + res.json({ players: await service.getKnownPlayers(server.id) }); + } catch (error) { + next(error); + } + }); + + router.get('/:slug/activity', async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + const limit = z.coerce.number().int().min(1).max(200).default(50).parse(req.query.limit); + res.json({ activity: await service.getActivity(server.id, limit) }); + } catch (error) { + next(error); + } + }); + + router.get('/:slug/killfeed', async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + const limit = z.coerce.number().int().min(1).max(500).default(100).parse(req.query.limit); + res.json({ events: await service.getKillfeed(server.id, limit) }); + } catch (error) { + next(error); + } + }); + + router.get('/:slug/configuration', async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + if (!deps.configSync) { + throw ApiError.notConfigured('Configuration requires a configured game server backend.'); + } + const config = await deps.configSync.getLiveConfig(server); + res.json({ config, fetchedAt: new Date().toISOString() }); + } catch (error) { + next(error); + } + }); + + router.get('/:slug/missions', async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + const logMissions = deps.missions ? (await deps.missions.list()).missions : []; + const modMissions = []; + if (deps.mods) { + const installed = await deps.mods.getMods(server); + const details = await Promise.allSettled( + installed.mods.map((mod) => deps.workshop.getMod(mod.modId)), + ); + for (const result of details) { + if (result.status === 'fulfilled') { + modMissions.push(...scenariosFromWorkshopMod(result.value)); + } + } + } + if (!deps.missions && !deps.mods) { + throw ApiError.notConfigured('Missions require logs or config/mod access.'); + } + res.json({ + missions: mergeMissions(logMissions, modMissions), + fetchedAt: new Date().toISOString(), + }); + } catch (error) { + next(error); + } + }); + + router.get( + '/:slug/logs/raw', + requireCapability('ops.health.view', 'Raw logs are restricted.'), + async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + if (!deps.resolveLogPath) { + throw ApiError.notConfigured('Log access requires a configured game server backend.'); + } + const lineCount = z.coerce + .number() + .int() + .min(10) + .max(1000) + .default(300) + .parse(req.query.lines); + const logPath = await deps.resolveLogPath(); + if (!logPath) throw ApiError.notConfigured('Could not locate the current log file.'); + const file = await provider.downloadTextFile(providerId(server), logPath, 512 * 1024); + const allLines = file.content.split('\n'); + res.json({ + path: logPath, + lines: allLines.slice(-lineCount), + truncated: file.truncated || allLines.length > lineCount, + fetchedAt: new Date().toISOString(), + }); + } catch (error) { + next(error); + } + }, + ); + + router.get( + '/:slug/startup', + requireCapability('config.edit', 'Startup variables are restricted.'), + async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + const variables = await provider.listStartupVariables(providerId(server)); + res.json({ + variables: variables.map((v) => ({ + name: v.name, + description: v.description, + envVariable: v.envVariable, + value: v.serverValue, + defaultValue: v.defaultValue, + isEditable: v.isEditable, + })), + fetchedAt: new Date().toISOString(), + }); + } catch (error) { + next(error); + } + }, + ); + + router.put( + '/:slug/startup/variable', + syncRateLimit, + requireCapability('config.edit', 'Startup variables are restricted.'), + async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + const body = startupVariableBodySchema.safeParse(req.body); + if (!body.success) throw ApiError.validation('Invalid startup variable update.'); + await provider.updateStartupVariable(providerId(server), body.data.key, body.data.value); + const user = req.user!; + // Never put the value in the activity feed — these can be passwords. + await service.recordActivity({ + serverId: server.id, + actorUserId: user.id, + action: 'startup.variable.updated', + summary: `Startup variable ${body.data.key} updated by ${user.displayName ?? user.username} (applies on restart)`, + metadata: { key: body.data.key }, + }); + res.json({ ok: true, requiresRestart: true }); + } catch (error) { + next(error); + } + }, + ); + + router.get( + '/:slug/schedules', + requireCapability('config.edit', 'Schedule management is restricted.'), + async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + res.json({ + schedules: await provider.listSchedules(providerId(server)), + fetchedAt: new Date().toISOString(), + }); + } catch (error) { + next(error); + } + }, + ); + + router.post( + '/:slug/schedules/restarts', + syncRateLimit, + requireCapability('config.edit', 'Schedule management is restricted.'), + async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + const body = restartScheduleBodySchema.safeParse(req.body); + if (!body.success) throw ApiError.validation('Invalid restart schedule.'); + const schedule = await provider.createRestartSchedule(providerId(server), body.data); + const user = req.user!; + await service.recordActivity({ + serverId: server.id, + actorUserId: user.id, + action: 'schedule.restart.created', + summary: `Restart schedule "${schedule.name}" created by ${user.displayName ?? user.username}`, + metadata: { scheduleId: schedule.id }, + }); + res.json({ schedule }); + } catch (error) { + next(error); + } + }, + ); + + router.put( + '/:slug/schedules/:scheduleId/restart', + syncRateLimit, + requireCapability('config.edit', 'Schedule management is restricted.'), + async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + const scheduleId = scheduleIdSchema.safeParse(req.params.scheduleId); + if (!scheduleId.success) throw ApiError.validation('Invalid schedule id.'); + const body = restartScheduleBodySchema.safeParse(req.body); + if (!body.success) throw ApiError.validation('Invalid restart schedule.'); + const schedule = await provider.updateRestartSchedule( + providerId(server), + scheduleId.data, + body.data, + ); + const user = req.user!; + await service.recordActivity({ + serverId: server.id, + actorUserId: user.id, + action: 'schedule.restart.updated', + summary: `Restart schedule "${schedule.name}" updated by ${user.displayName ?? user.username}`, + metadata: { scheduleId: schedule.id }, + }); + res.json({ schedule }); + } catch (error) { + next(error); + } + }, + ); + + router.delete( + '/:slug/schedules/:scheduleId', + syncRateLimit, + requireCapability('config.edit', 'Schedule management is restricted.'), + async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + const scheduleId = scheduleIdSchema.safeParse(req.params.scheduleId); + if (!scheduleId.success) throw ApiError.validation('Invalid schedule id.'); + await provider.deleteSchedule(providerId(server), scheduleId.data); + const user = req.user!; + await service.recordActivity({ + serverId: server.id, + actorUserId: user.id, + action: 'schedule.deleted', + summary: `Schedule deleted by ${user.displayName ?? user.username}`, + metadata: { scheduleId: scheduleId.data }, + }); + res.json({ ok: true }); + } catch (error) { + next(error); + } + }, + ); + + router.get('/:slug/mods', async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + if (!deps.mods) { + throw ApiError.notConfigured('Mod management requires a configured game server backend.'); + } + res.json(await deps.mods.getMods(server)); + } catch (error) { + next(error); + } + }); + + router.put( + '/:slug/mods', + syncRateLimit, + requireCapability('mods.manage', 'You do not have permission to manage mods.'), + async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + if (!deps.mods) { + throw ApiError.notConfigured('Mod management requires a configured game server backend.'); + } + const body = modsBodySchema.safeParse(req.body); + if (!body.success) { + throw ApiError.validation(body.error.issues[0]?.message ?? 'Invalid mod list.'); + } + // Reject duplicate mod ids up front instead of silently collapsing. + const ids = body.data.mods.map((mod) => mod.modId.toUpperCase()); + if (new Set(ids).size !== ids.length) { + throw ApiError.validation('Duplicate mod ids in the list.'); + } + + const result = await deps.mods.setMods(server, body.data.mods); + const user = req.user!; + await service.recordActivity({ + serverId: server.id, + actorUserId: user.id, + action: 'mods.updated', + summary: `Mods updated by ${user.displayName ?? user.username}: ${result.added} added, ${result.removed} removed (${result.mods.length} total, applies on restart)`, + metadata: { added: result.added, removed: result.removed, total: result.mods.length }, + }); + res.json(result); + } catch (error) { + next(error); + } + }, + ); + + router.get('/:slug/mod-packs', async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + res.json({ modPacks: await service.getModPacks(server.id) }); + } catch (error) { + next(error); + } + }); + + const powerActions = [ + { + action: 'start' as const, + capability: 'server.power.start' as const, + message: 'You do not have permission to start this server.', + run: (id: string) => provider.startServer(id), + }, + { + action: 'stop' as const, + capability: 'server.power.stop' as const, + message: 'You do not have permission to stop this server.', + run: (id: string) => provider.stopServer(id), + }, + { + action: 'restart' as const, + capability: 'server.power.restart' as const, + message: 'You do not have permission to restart this server.', + run: (id: string) => provider.restartServer(id), + }, + ]; + + for (const { action, capability, message, run } of powerActions) { + router.post( + `/:slug/power/${action}`, + powerRateLimit, + requireCapability(capability, message), + async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + await run(providerId(server)); + const user = req.user!; + await service.recordActivity({ + serverId: server.id, + actorUserId: user.id, + action: `server.power.${action}`, + summary: `Server ${action} requested by ${user.displayName ?? user.username}${ + deps.mockMode ? ' (mock mode)' : '' + }`, + metadata: { action, mock: deps.mockMode }, + }); + res.json({ ok: true, action, simulated: deps.mockMode }); + } catch (error) { + next(error); + } + }, + ); + } + + router.post( + '/:slug/logs/sync', + syncRateLimit, + requireCapability('logs.sync', 'Only the owner can trigger a manual log sync.'), + async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + if (!deps.scheduler || !deps.resolveLogPath) { + throw ApiError.notConfigured( + 'Log ingestion is not configured. Set REFORGER_LOG_DIRECTORY (or REFORGER_ADMIN_LOG_PATH) and the Pterodactyl variables.', + ); + } + const target: ScheduledServer = { + serverId: server.id, + providerServerId: providerId(server), + resolveLogPath: deps.resolveLogPath, + }; + const result = await deps.scheduler.syncNow(target); + const user = req.user!; + await service.recordActivity({ + serverId: server.id, + actorUserId: user.id, + action: 'logs.sync.manual', + summary: `Manual log sync by ${user.displayName ?? user.username} (${result.createdEvents} new events)`, + metadata: { createdEvents: result.createdEvents, processedLines: result.processedLines }, + }); + res.json(result); + } catch (error) { + next(error); + } + }, + ); + + router.post( + '/:slug/config/sync', + syncRateLimit, + requireCapability('ops.health.view', 'Config sync is restricted to owner and server admins.'), + async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + if (!deps.configSync) { + throw ApiError.notConfigured('Config import requires a configured game server backend.'); + } + // Config is served live; this just refreshes the stored name/capacity. + const result = await deps.configSync.sync(server); + res.json({ ok: true, serverName: result.serverName, maxPlayers: result.maxPlayers }); + } catch (error) { + next(error); + } + }, + ); + + router.get( + '/:slug/logs/health', + requireCapability('ops.health.view', 'Operational diagnostics are restricted.'), + async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + const cursor = await service.getLogCursor(server.id); + const lastResult = deps.scheduler?.getLastResult(server.id) ?? null; + const lastSyncAt = cursor?.lastSuccessfulSyncAt ?? null; + const body: LogIngestionHealth = { + configured: Boolean(deps.scheduler && deps.resolveLogPath), + running: Boolean(deps.scheduler), + logPath: cursor?.logPath ?? null, + lastSuccessfulSyncAt: lastSyncAt?.toISOString() ?? null, + lastErrorAt: cursor?.lastErrorAt?.toISOString() ?? null, + lastErrorMessage: cursor?.lastErrorMessage ?? null, + lastSync: lastResult + ? { + processedLines: lastResult.processedLines, + createdEvents: lastResult.createdEvents, + updatedSessions: lastResult.updatedSessions, + } + : null, + stale: !lastSyncAt || Date.now() - lastSyncAt.getTime() > deps.staleAfterSeconds * 1000, + }; + res.json(body); + } catch (error) { + next(error); + } + }, + ); + + return router; +} diff --git a/apps/api/src/modules/servers/server-service.ts b/apps/api/src/modules/servers/server-service.ts new file mode 100644 index 0000000..998d13d --- /dev/null +++ b/apps/api/src/modules/servers/server-service.ts @@ -0,0 +1,259 @@ +import { and, count, desc, eq, isNull, sql } from 'drizzle-orm'; +import type { + ActivityItem, + KillfeedEvent, + KnownPlayer, + ModPackSummary, + OnlinePlayer, + PlayersResponse, +} from '@reforger-panel/shared'; +import type { Db } from '../../db/client.js'; +import { schema } from '../../db/client.js'; + +export type ServerRecord = typeof schema.servers.$inferSelect; + +export class ServerService { + constructor(private readonly db: Db) {} + + async listServers(): Promise { + return this.db.select().from(schema.servers).orderBy(schema.servers.name); + } + + async getServerBySlug(slug: string): Promise { + const rows = await this.db.select().from(schema.servers).where(eq(schema.servers.slug, slug)); + return rows[0] ?? null; + } + + async updateStatus(serverId: string, status: string): Promise { + await this.db.update(schema.servers).set({ status }).where(eq(schema.servers.id, serverId)); + } + + async updateServerInfo( + serverId: string, + patch: { name?: string; maxPlayers?: number | null }, + ): Promise { + await this.db.update(schema.servers).set(patch).where(eq(schema.servers.id, serverId)); + } + + async countOnlinePlayers(serverId: string): Promise { + const rows = await this.db + .select({ value: count() }) + .from(schema.playerSessions) + .where( + and( + eq(schema.playerSessions.serverId, serverId), + isNull(schema.playerSessions.disconnectedAt), + ), + ); + return rows[0]?.value ?? 0; + } + + async getOnlinePlayers( + server: ServerRecord, + staleAfterSeconds: number, + ): Promise { + const rows = await this.db + .select({ session: schema.playerSessions, player: schema.players }) + .from(schema.playerSessions) + .innerJoin(schema.players, eq(schema.players.id, schema.playerSessions.playerId)) + .where( + and( + eq(schema.playerSessions.serverId, server.id), + isNull(schema.playerSessions.disconnectedAt), + ), + ) + .orderBy(schema.playerSessions.connectedAt); + + const now = Date.now(); + const players: OnlinePlayer[] = rows.map(({ session, player }) => ({ + playerId: player.id, + displayName: player.displayName, + externalPlayerId: player.externalPlayerId, + connectedAt: session.connectedAt.toISOString(), + sessionDurationSeconds: Math.max(0, Math.round((now - session.connectedAt.getTime()) / 1000)), + })); + + const cursors = await this.db + .select() + .from(schema.logCursors) + .where(eq(schema.logCursors.serverId, server.id)); + const lastSyncedAt = cursors + .map((c) => c.lastSuccessfulSyncAt) + .filter((d): d is Date => d !== null) + .sort((a, b) => b.getTime() - a.getTime())[0]; + + return { + players, + onlineCount: players.length, + maxPlayers: server.maxPlayers, + lastSyncedAt: lastSyncedAt?.toISOString() ?? null, + stale: !lastSyncedAt || now - lastSyncedAt.getTime() > staleAfterSeconds * 1000, + }; + } + + async getKnownPlayers(serverId: string, limit = 100): Promise { + const rows = await this.db + .select({ + player: schema.players, + totalSessions: count(schema.playerSessions.id), + totalPlaytimeSeconds: sql`coalesce(sum(${schema.playerSessions.durationSeconds}), 0)`, + openSessions: sql`count(*) filter (where ${schema.playerSessions.disconnectedAt} is null)`, + }) + .from(schema.players) + .leftJoin(schema.playerSessions, eq(schema.playerSessions.playerId, schema.players.id)) + .where(eq(schema.players.serverId, serverId)) + .groupBy(schema.players.id) + .orderBy(desc(schema.players.lastSeenAt)) + .limit(limit); + + return rows.map(({ player, totalSessions, totalPlaytimeSeconds, openSessions }) => ({ + id: player.id, + displayName: player.displayName, + externalPlayerId: player.externalPlayerId, + firstSeenAt: player.firstSeenAt.toISOString(), + lastSeenAt: player.lastSeenAt.toISOString(), + totalSessions, + totalPlaytimeSeconds: Number(totalPlaytimeSeconds), + online: Number(openSessions) > 0, + })); + } + + /** Merged feed of panel actions (server_activity) and log-derived server events. */ + async getActivity(serverId: string, limit = 50): Promise { + const actions = await this.db + .select({ activity: schema.serverActivity, actor: schema.users }) + .from(schema.serverActivity) + .leftJoin(schema.users, eq(schema.users.id, schema.serverActivity.actorUserId)) + .where(eq(schema.serverActivity.serverId, serverId)) + .orderBy(desc(schema.serverActivity.createdAt)) + .limit(limit); + + const events = await this.db + .select() + .from(schema.serverEvents) + .where(eq(schema.serverEvents.serverId, serverId)) + .orderBy(desc(schema.serverEvents.occurredAt)) + .limit(limit); + + const items: ActivityItem[] = [ + ...actions.map(({ activity, actor }) => ({ + id: `activity:${activity.id}`, + kind: 'panel_action' as const, + action: activity.action, + summary: activity.summary, + actor: actor + ? { id: actor.id, username: actor.username, displayName: actor.displayName } + : null, + occurredAt: activity.createdAt.toISOString(), + })), + ...events.map((event) => ({ + id: `event:${event.id}`, + kind: 'server_event' as const, + action: event.eventType, + summary: event.summary, + actor: null, + occurredAt: event.occurredAt.toISOString(), + })), + ]; + items.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)); + return items.slice(0, limit); + } + + async getKillfeed(serverId: string, limit = 100): Promise { + const events = await this.db + .select() + .from(schema.serverEvents) + .where( + and( + eq(schema.serverEvents.serverId, serverId), + eq(schema.serverEvents.eventType, 'player_killed'), + ), + ) + .orderBy(desc(schema.serverEvents.occurredAt)) + .limit(limit); + + return events.map((event) => { + const payload = event.payload as Record; + const position = (value: unknown) => { + if (!value || typeof value !== 'object') return null; + const record = value as Record; + return typeof record.x === 'number' && typeof record.y === 'number' + ? { + x: record.x, + y: record.y, + z: typeof record.z === 'number' ? record.z : null, + } + : null; + }; + return { + id: event.id, + occurredAt: event.occurredAt.toISOString(), + killerName: typeof payload.killerName === 'string' ? payload.killerName : 'unknown', + victimName: typeof payload.victimName === 'string' ? payload.victimName : 'unknown', + friendly: payload.friendly === true, + killerTeam: typeof payload.killerTeam === 'string' ? payload.killerTeam : null, + victimTeam: typeof payload.victimTeam === 'string' ? payload.victimTeam : null, + killerPosition: position(payload.killerPosition), + victimPosition: position(payload.victimPosition), + distanceMeters: typeof payload.distanceMeters === 'number' ? payload.distanceMeters : null, + weapon: typeof payload.weapon === 'string' ? payload.weapon : null, + }; + }); + } + + async recordActivity(input: { + serverId: string; + actorUserId: string | null; + action: string; + summary: string; + metadata?: Record; + }): Promise { + await this.db.insert(schema.serverActivity).values({ + serverId: input.serverId, + actorUserId: input.actorUserId, + action: input.action, + summary: input.summary, + metadata: input.metadata ?? {}, + }); + } + + async getModPacks(serverId: string): Promise { + const packs = await this.db + .select() + .from(schema.modPacks) + .where(eq(schema.modPacks.serverId, serverId)) + .orderBy(desc(schema.modPacks.updatedAt)); + + const result: ModPackSummary[] = []; + for (const pack of packs) { + const revisions = await this.db + .select() + .from(schema.modPackRevisions) + .where(eq(schema.modPackRevisions.modPackId, pack.id)) + .orderBy(desc(schema.modPackRevisions.version)) + .limit(1); + const latest = revisions[0]; + const mods = (latest?.mods ?? []) as unknown[]; + result.push({ + id: pack.id, + name: pack.name, + description: pack.description, + status: pack.status, + modCount: Array.isArray(mods) ? mods.length : 0, + latestVersion: latest?.version ?? null, + updatedAt: pack.updatedAt.toISOString(), + }); + } + return result; + } + + async getLogCursor(serverId: string) { + const rows = await this.db + .select() + .from(schema.logCursors) + .where(eq(schema.logCursors.serverId, serverId)) + .orderBy(desc(schema.logCursors.updatedAt)) + .limit(1); + return rows[0] ?? null; + } +} diff --git a/apps/api/src/modules/users/user-routes.ts b/apps/api/src/modules/users/user-routes.ts new file mode 100644 index 0000000..fe90879 --- /dev/null +++ b/apps/api/src/modules/users/user-routes.ts @@ -0,0 +1,60 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { desc } from 'drizzle-orm'; +import { eq } from 'drizzle-orm'; +import type { PanelUser } from '@reforger-panel/shared'; +import { ROLES } from '@reforger-panel/shared'; +import type { Db } from '../../db/client.js'; +import { schema } from '../../db/client.js'; +import { ApiError } from '../../lib/errors.js'; +import { requireCapability } from '../auth/auth-middleware.js'; + +const roleBodySchema = z.object({ role: z.enum(ROLES as [string, ...string[]]) }); + +export function createUserRouter(db: Db): Router { + const router = Router(); + + router.use(requireCapability('users.manage', 'Only the owner can manage users.')); + + router.get('/', async (_req, res, next) => { + try { + const rows = await db.select().from(schema.users).orderBy(desc(schema.users.createdAt)); + const users: PanelUser[] = rows.map((user) => ({ + id: user.id, + discordId: user.discordId, + username: user.username, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + role: user.role, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + })); + res.json({ users }); + } catch (error) { + next(error); + } + }); + + router.patch('/:id/role', async (req, res, next) => { + try { + const id = z.string().uuid().safeParse(req.params.id); + if (!id.success) throw ApiError.validation('Invalid user id.'); + const body = roleBodySchema.safeParse(req.body); + if (!body.success) throw ApiError.validation('Invalid role.'); + if (req.user!.id === id.data) { + throw ApiError.validation('You cannot change your own role.'); + } + const [updated] = await db + .update(schema.users) + .set({ role: body.data.role as (typeof ROLES)[number] }) + .where(eq(schema.users.id, id.data)) + .returning(); + if (!updated) throw ApiError.notFound('User not found.'); + res.json({ ok: true }); + } catch (error) { + next(error); + } + }); + + return router; +} diff --git a/apps/api/src/modules/workshop/workshop-client.test.ts b/apps/api/src/modules/workshop/workshop-client.test.ts new file mode 100644 index 0000000..7d6254f --- /dev/null +++ b/apps/api/src/modules/workshop/workshop-client.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest'; +import { WorkshopClient, normalizeImageUrl } from './workshop-client.js'; + +const REAL_IMAGE = 'https://ar-gcp-cdn.bistudio.com/image/abcd/1234'; + +function listResponse() { + return { + status: 'success', + meta: { totalPages: 1, currentPage: 1, totalMods: 2, shownMods: 2 }, + data: [ + { + name: 'Mod A', + author: 'Author', + imageURL: 'https://via.placeholder.com/640x360', + originalModURL: 'https://reforger.armaplatform.com/workshop/AAAAAAAAAAAAAAA1', + apiModURL: 'https://api.reforgermods.net/v1/mod/AAAAAAAAAAAAAAA1', + size: '1 MB', + rating: '99%', + ID: 'AAAAAAAAAAAAAAA1', + }, + ], + }; +} + +function detailResponse(id: string) { + return { + status: 'success', + mod: { + name: 'Mod A', + author: 'Author', + originalModURL: `https://reforger.armaplatform.com/workshop/${id}`, + apiModURL: `https://api.reforgermods.net/v1/mod/${id}`, + // Upstream bug: two URLs concatenated. + imageURL: `https://reforger.armaplatform.com${REAL_IMAGE}`, + rating: '99%', + version: '1.2.0', + size: '1 MB', + id, + tags: [], + dependencies: [], + scenarios: [], + }, + }; +} + +describe('normalizeImageUrl', () => { + it('drops dead placeholder URLs', () => { + expect(normalizeImageUrl('https://via.placeholder.com/640x360')).toBeNull(); + }); + + it('repairs concatenated double URLs', () => { + expect(normalizeImageUrl(`https://reforger.armaplatform.com${REAL_IMAGE}`)).toBe(REAL_IMAGE); + }); + + it('passes through well-formed URLs and rejects junk', () => { + expect(normalizeImageUrl(REAL_IMAGE)).toBe(REAL_IMAGE); + expect(normalizeImageUrl('')).toBeNull(); + expect(normalizeImageUrl('not a url')).toBeNull(); + }); +}); + +describe('WorkshopClient image enrichment', () => { + it('warms list images from the detail endpoint in the background and caches them', async () => { + const fetchImpl = vi.fn(async (url: string | URL) => { + const path = String(url); + if (path.includes('/v1/mod/')) { + const id = path.slice(path.lastIndexOf('/') + 1); + return new Response(JSON.stringify(detailResponse(id)), { status: 200 }); + } + return new Response(JSON.stringify(listResponse()), { status: 200 }); + }); + const client = new WorkshopClient({ + baseUrl: 'https://workshop.test', + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + const first = await client.search('', 1); + expect(first.mods[0]!.imageUrl).toBeNull(); + await vi.waitFor(() => { + const detailCalls = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/')); + expect(detailCalls).toHaveLength(1); + }); + const detailCalls = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/')); + expect(detailCalls).toHaveLength(1); + + // Second search hits the cache — no extra detail request. + const second = await client.search('', 1); + expect(second.mods[0]!.imageUrl).toBe(REAL_IMAGE); + const detailCallsAfter = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/')); + expect(detailCallsAfter).toHaveLength(1); + }); + + it('leaves the image empty when the detail fetch fails', async () => { + const fetchImpl = vi.fn(async (url: string | URL) => { + const path = String(url); + if (path.includes('/v1/mod/')) { + return new Response('nope', { status: 500 }); + } + return new Response(JSON.stringify(listResponse()), { status: 200 }); + }); + const client = new WorkshopClient({ + baseUrl: 'https://workshop.test', + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + const result = await client.search('', 1); + expect(result.mods[0]!.imageUrl).toBeNull(); + }); +}); diff --git a/apps/api/src/modules/workshop/workshop-client.ts b/apps/api/src/modules/workshop/workshop-client.ts new file mode 100644 index 0000000..8dc83fe --- /dev/null +++ b/apps/api/src/modules/workshop/workshop-client.ts @@ -0,0 +1,282 @@ +import { z } from 'zod'; +import type { + WorkshopHealth, + WorkshopModDetail, + WorkshopModPreview, + WorkshopSearchResponse, +} from '@reforger-panel/shared'; +import { ApiError } from '../../lib/errors.js'; +import { sanitizeErrorMessage } from '../../lib/logger.js'; + +/** + * Client for the public reforgermods.net Workshop metadata API. + * Endpoint shapes follow https://reforgermods.net/?page=documentation/api: + * GET /v1/health + * GET /v1/mods/{page}?search={q}&sort={sort} + * GET /v1/mod/{mod_id} + * Backend-only — the browser never talks to this host directly. + */ + +const modPreviewSchema = z.object({ + name: z.string(), + author: z.string().catch('Unknown'), + imageURL: z.string().catch(''), + originalModURL: z.string().catch(''), + size: z.string().catch(''), + rating: z.string().catch(''), + ID: z.string(), +}); + +const searchResponseSchema = z.object({ + status: z.string(), + meta: z.object({ + totalPages: z.number().catch(1), + currentPage: z.number().catch(1), + totalMods: z.number().catch(0), + }), + data: z.array(modPreviewSchema).catch([]), +}); + +const modDetailSchema = z.object({ + name: z.string(), + author: z.string().catch('Unknown'), + originalModURL: z.string().catch(''), + imageURL: z.string().catch(''), + rating: z.string().catch(''), + version: z.string().nullish(), + gameVersion: z.string().nullish(), + size: z.string().catch(''), + subscribers: z.number().nullish(), + downloads: z.number().nullish(), + created: z.string().nullish(), + lastModified: z.string().nullish(), + id: z.string(), + summary: z.string().nullish(), + description: z.string().nullish(), + license: z.string().nullish(), + tags: z.array(z.string()).catch([]), + dependencies: z.array(z.object({ name: z.string(), apiModURL: z.string().catch('') })).catch([]), + scenarios: z + .array( + z.object({ + name: z.string(), + description: z.string().catch(''), + scenarioID: z.string(), + gamemode: z.string().catch(''), + playerCount: z.number().catch(0), + imageURL: z.string().catch(''), + }), + ) + .catch([]), +}); + +const modDetailEnvelopeSchema = z.object({ status: z.string(), mod: modDetailSchema }); + +export type WorkshopSort = 'popularity' | 'newest' | 'subscribers' | 'version_size'; + +function extractModId(apiModUrl: string): string | null { + const match = /\/v1\/mod\/([^/?#]+)/.exec(apiModUrl); + return match?.[1] ?? null; +} + +/** + * Upstream image URLs need repair: list endpoints return dead + * via.placeholder.com stubs, and detail endpoints sometimes concatenate two + * URLs ("https://reforger.armaplatform.comhttps://ar-gcp-cdn..."). + */ +export function normalizeImageUrl(raw: string | null | undefined): string | null { + if (!raw) return null; + if (raw.includes('via.placeholder.com')) return null; + const lastScheme = raw.lastIndexOf('https://'); + const candidate = lastScheme > 0 ? raw.slice(lastScheme) : raw; + return candidate.startsWith('http') ? candidate : null; +} + +function toPreview(mod: z.infer): WorkshopModPreview { + return { + id: mod.ID, + name: mod.name, + author: mod.author, + imageUrl: normalizeImageUrl(mod.imageURL), + size: mod.size || null, + rating: mod.rating || null, + workshopUrl: mod.originalModURL || null, + }; +} + +const IMAGE_CACHE_TTL_MS = 60 * 60 * 1000; // matches upstream's 1 h detail cache +const IMAGE_FETCH_CONCURRENCY = 5; + +export class WorkshopClient { + private readonly baseUrl: string; + private readonly fetchImpl: typeof fetch; + private readonly timeoutMs: number; + /** modId → real image URL (or null when the mod has none). */ + private imageCache = new Map(); + + constructor(options: { baseUrl: string; fetchImpl?: typeof fetch; timeoutMs?: number }) { + this.baseUrl = options.baseUrl.replace(/\/$/, ''); + this.fetchImpl = options.fetchImpl ?? fetch; + this.timeoutMs = options.timeoutMs ?? 10_000; + } + + private async get(path: string): Promise { + let response: Response; + try { + response = await this.fetchImpl(`${this.baseUrl}${path}`, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(this.timeoutMs), + }); + } catch (error) { + const reason = + error instanceof Error && error.name === 'TimeoutError' ? 'timed out' : 'failed'; + throw ApiError.upstream(`Workshop API request ${reason}.`); + } + if (response.status === 404) { + throw ApiError.notFound('Workshop mod not found.'); + } + if (response.status === 429) { + throw ApiError.rateLimited('Workshop API rate limit reached. Try again shortly.'); + } + if (!response.ok) { + throw ApiError.upstream(`Workshop API returned HTTP ${response.status}.`); + } + return response.json(); + } + + async health(): Promise { + const startedAt = Date.now(); + try { + await this.get('/v1/health'); + return { + ok: true, + latencyMs: Date.now() - startedAt, + checkedAt: new Date().toISOString(), + message: null, + }; + } catch (error) { + return { + ok: false, + latencyMs: null, + checkedAt: new Date().toISOString(), + message: sanitizeErrorMessage(error), + }; + } + } + + async search(query: string, page = 1, sort?: WorkshopSort): Promise { + const params = new URLSearchParams(); + if (query) params.set('search', query); + if (sort) params.set('sort', sort); + const qs = params.size > 0 ? `?${params.toString()}` : ''; + const raw = await this.get(`/v1/mods/${Math.max(1, page)}${qs}`); + const parsed = searchResponseSchema.safeParse(raw); + if (!parsed.success) { + throw ApiError.upstream('Workshop API returned an unexpected response shape.'); + } + const mods = parsed.data.data.map(toPreview); + this.applyCachedImages(mods); + void this.enrichImages(mods).catch(() => undefined); + return { + mods, + meta: parsed.data.meta, + }; + } + + private applyCachedImages(mods: WorkshopModPreview[]): void { + const now = Date.now(); + for (const mod of mods) { + if (mod.imageUrl) continue; + const cached = this.imageCache.get(mod.id); + if (cached && cached.expiresAt > now) { + mod.imageUrl = cached.url; + } + } + } + + /** + * List responses carry no usable images, so fill them in from the detail + * endpoint (which does). This runs as a background cache warmer from search: + * first-load results are fast, later visits pick up cached images. + */ + private async enrichImages(mods: WorkshopModPreview[]): Promise { + const now = Date.now(); + const pending: WorkshopModPreview[] = []; + for (const mod of mods) { + if (mod.imageUrl) continue; + const cached = this.imageCache.get(mod.id); + if (cached && cached.expiresAt > now) { + mod.imageUrl = cached.url; + } else { + pending.push(mod); + } + } + if (pending.length === 0) return; + + const queue = [...pending]; + const worker = async () => { + for (;;) { + const mod = queue.shift(); + if (!mod) return; + try { + const detail = await this.getMod(mod.id); + mod.imageUrl = detail.imageUrl; + } catch { + mod.imageUrl = null; + } + this.imageCache.set(mod.id, { + url: mod.imageUrl, + expiresAt: Date.now() + IMAGE_CACHE_TTL_MS, + }); + } + }; + await Promise.all( + Array.from({ length: Math.min(IMAGE_FETCH_CONCURRENCY, queue.length) }, () => worker()), + ); + if (this.imageCache.size > 5_000) { + for (const [key, value] of this.imageCache) { + if (value.expiresAt <= now) this.imageCache.delete(key); + } + } + } + + async getMod(modId: string): Promise { + const raw = await this.get(`/v1/mod/${encodeURIComponent(modId)}`); + const parsed = modDetailEnvelopeSchema.safeParse(raw); + if (!parsed.success) { + throw ApiError.upstream('Workshop API returned an unexpected response shape.'); + } + const mod = parsed.data.mod; + return { + id: mod.id, + name: mod.name, + author: mod.author, + imageUrl: normalizeImageUrl(mod.imageURL), + size: mod.size || null, + rating: mod.rating || null, + workshopUrl: mod.originalModURL || null, + version: mod.version ?? null, + gameVersion: mod.gameVersion ?? null, + subscribers: mod.subscribers ?? null, + downloads: mod.downloads ?? null, + createdAtText: mod.created ?? null, + lastModifiedText: mod.lastModified ?? null, + summary: mod.summary ?? null, + description: mod.description ?? null, + license: mod.license ?? null, + tags: mod.tags, + dependencies: mod.dependencies.map((dep) => ({ + name: dep.name, + id: extractModId(dep.apiModURL), + })), + scenarios: mod.scenarios.map((scenario) => ({ + name: scenario.name, + description: scenario.description || null, + scenarioId: scenario.scenarioID, + gamemode: scenario.gamemode || null, + playerCount: scenario.playerCount || null, + imageUrl: normalizeImageUrl(scenario.imageURL), + })), + }; + } +} diff --git a/apps/api/src/modules/workshop/workshop-routes.ts b/apps/api/src/modules/workshop/workshop-routes.ts new file mode 100644 index 0000000..2e88824 --- /dev/null +++ b/apps/api/src/modules/workshop/workshop-routes.ts @@ -0,0 +1,57 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { ApiError } from '../../lib/errors.js'; +import { rateLimit } from '../../lib/rate-limit.js'; +import { requireAuth } from '../auth/auth-middleware.js'; +import type { WorkshopClient } from './workshop-client.js'; + +const searchQuerySchema = z.object({ + q: z.string().trim().max(100).default(''), + page: z.coerce.number().int().min(1).max(10_000).default(1), + sort: z.enum(['popularity', 'newest', 'subscribers', 'version_size']).optional(), +}); + +const modIdSchema = z.string().regex(/^[A-Za-z0-9]{1,32}$/, 'Invalid mod id.'); + +export function createWorkshopRouter(client: WorkshopClient): Router { + const router = Router(); + // The upstream allows 60 req/min per IP; stay well under it. + const workshopRateLimit = rateLimit({ windowMs: 60_000, max: 30, keyPrefix: 'workshop' }); + + router.use(requireAuth, workshopRateLimit); + + router.get('/health', async (_req, res, next) => { + try { + res.json(await client.health()); + } catch (error) { + next(error); + } + }); + + router.get('/search', async (req, res, next) => { + try { + const parsed = searchQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw ApiError.validation('Invalid search parameters.'); + } + const { q, page, sort } = parsed.data; + res.json(await client.search(q, page, sort)); + } catch (error) { + next(error); + } + }); + + router.get('/mods/:id', async (req, res, next) => { + try { + const parsed = modIdSchema.safeParse(req.params.id); + if (!parsed.success) { + throw ApiError.validation('Invalid mod id.'); + } + res.json(await client.getMod(parsed.data)); + } catch (error) { + next(error); + } + }); + + return router; +} diff --git a/apps/api/test/auth-routes.test.ts b/apps/api/test/auth-routes.test.ts new file mode 100644 index 0000000..3051d79 --- /dev/null +++ b/apps/api/test/auth-routes.test.ts @@ -0,0 +1,426 @@ +import { describe, expect, it } from 'vitest'; +import request from 'supertest'; +import type { Role } from '@reforger-panel/shared'; +import { createApp } from '../src/app.js'; +import { loadEnv } from '../src/env.js'; +import { createLogger } from '../src/lib/logger.js'; +import type { Db } from '../src/db/client.js'; +import { + resolveRoleForLogin, + type SessionService, + type SessionUser, +} from '../src/modules/auth/session-service.js'; +import type { ServerModsService } from '../src/modules/config/mods-service.js'; +import type { PerformanceSettingsService } from '../src/modules/config/performance-service.js'; +import type { ResourceHistoryService } from '../src/modules/servers/resource-history.js'; +import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js'; +import type { IngestionScheduler } from '../src/modules/reforger-logs/ingestion/scheduler.js'; +import type { ServerRecord, ServerService } from '../src/modules/servers/server-service.js'; +import { WorkshopClient } from '../src/modules/workshop/workshop-client.js'; + +const OWNER_ID = '111111111111111111'; + +const TEST_ENV = { + NODE_ENV: 'test', + DATABASE_URL: 'postgresql://unused', + SESSION_SECRET: 'a'.repeat(40), + OWNER_DISCORD_ID: OWNER_ID, + USE_MOCK_PTERODACTYL: 'true', +}; + +function makeUser(role: Role): SessionUser { + return { + id: `user-${role}`, + discordId: `discord-${role}`, + username: role, + displayName: role, + avatarUrl: null, + role, + }; +} + +const TOKENS: Record = { + 'owner-token': makeUser('owner'), + 'admin-token': makeUser('server_admin'), + 'lead-token': makeUser('mission_lead'), + 'viewer-token': makeUser('viewer'), +}; + +const trainingServer: ServerRecord = { + id: 'srv-1', + slug: 'training-server', + name: 'Training Server', + providerType: 'pterodactyl', + pterodactylServerId: null, + status: 'online', + maxPlayers: 20, + createdAt: new Date(), + updatedAt: new Date(), +}; + +function buildApp() { + const env = loadEnv(TEST_ENV as NodeJS.ProcessEnv); + const provider = new MockGameServerProvider(); + const activity: { action: string; actorUserId: string | null }[] = []; + + const sessions = { + getUserBySessionToken: async (token: string) => TOKENS[token] ?? null, + revokeSession: async () => undefined, + } as unknown as SessionService; + + const servers = { + getServerBySlug: async (slug: string) => (slug === 'training-server' ? trainingServer : null), + listServers: async () => [trainingServer], + countOnlinePlayers: async () => 0, + updateStatus: async () => undefined, + recordActivity: async (input: { action: string; actorUserId: string | null }) => { + activity.push(input); + }, + getOnlinePlayers: async () => ({ + players: [], + onlineCount: 0, + maxPlayers: 20, + lastSyncedAt: null, + stale: true, + }), + getActivity: async () => [], + getConfiguration: async () => ({ current: null, history: [] }), + getModPacks: async () => [], + getKnownPlayers: async () => [], + getLogCursor: async () => null, + } as unknown as ServerService; + + const scheduler = { + syncNow: async () => ({ + serverId: 'srv-1', + logPath: '/profile/logs/console.log', + fetchedBytes: 0, + processedLines: 0, + createdEvents: 0, + updatedSessions: 0, + cursorReset: false, + startedAt: new Date().toISOString(), + finishedAt: new Date().toISOString(), + ignoredLines: 0, + invalidTimestamps: 0, + reason: 'no_new_data', + }), + getLastResult: () => null, + } as unknown as IngestionScheduler; + + const app = createApp({ + env, + logger: createLogger('silent'), + db: {} as Db, + sessions, + servers, + provider, + workshop: new WorkshopClient({ baseUrl: 'https://workshop.invalid' }), + scheduler, + resolveLogPath: async () => '/profile/logs/console.log', + configSync: null, + mods: { + getMods: async () => ({ mods: [], fetchedAt: new Date().toISOString() }), + setMods: async () => ({ + mods: [], + fetchedAt: new Date().toISOString(), + added: 0, + removed: 0, + requiresRestart: true as const, + }), + } as unknown as ServerModsService, + performance: { + get: async () => ({ settings: {}, fetchedAt: new Date().toISOString() }), + update: async (_server: unknown, settings: unknown) => ({ + settings, + fetchedAt: new Date().toISOString(), + changedFields: [], + requiresRestart: true as const, + }), + } as unknown as PerformanceSettingsService, + resourceHistory: { + history: () => ({ samples: [], intervalSeconds: 15 }), + } as unknown as ResourceHistoryService, + missions: null, + }); + return { app, provider, activity }; +} + +function asUser(token: string) { + return { Cookie: `rp_session=${token}`, 'X-CSRF-Protection': '1' }; +} + +describe('authentication and roles', () => { + it('rejects unauthenticated requests to /api/auth/me', async () => { + const { app } = buildApp(); + const response = await request(app).get('/api/auth/me'); + expect(response.status).toBe(401); + expect(response.body.error.code).toBe('UNAUTHENTICATED'); + }); + + it('returns the current user with capabilities', async () => { + const { app } = buildApp(); + const response = await request(app).get('/api/auth/me').set(asUser('viewer-token')); + expect(response.status).toBe(200); + expect(response.body.role).toBe('viewer'); + expect(response.body.capabilities).toEqual(['server.view']); + }); + + it('bootstraps the owner role from OWNER_DISCORD_ID and defaults others to viewer', () => { + expect(resolveRoleForLogin(null, OWNER_ID, OWNER_ID)).toBe('owner'); + expect(resolveRoleForLogin('viewer', OWNER_ID, OWNER_ID)).toBe('owner'); + expect(resolveRoleForLogin(null, '222', OWNER_ID)).toBe('viewer'); + expect(resolveRoleForLogin('server_admin', '222', OWNER_ID)).toBe('server_admin'); + // No owner configured: nobody is silently promoted. + expect(resolveRoleForLogin(null, '', '')).toBe('viewer'); + }); + + it('requires authentication on server routes', async () => { + const { app } = buildApp(); + const response = await request(app).get('/api/servers'); + expect(response.status).toBe(401); + }); +}); + +describe('power controls by role', () => { + const cases: { token: string; action: string; expected: number }[] = [ + { token: 'owner-token', action: 'start', expected: 200 }, + { token: 'owner-token', action: 'stop', expected: 200 }, + { token: 'owner-token', action: 'restart', expected: 200 }, + { token: 'admin-token', action: 'start', expected: 200 }, + { token: 'admin-token', action: 'stop', expected: 200 }, + { token: 'admin-token', action: 'restart', expected: 200 }, + { token: 'lead-token', action: 'restart', expected: 200 }, + { token: 'lead-token', action: 'start', expected: 403 }, + { token: 'lead-token', action: 'stop', expected: 403 }, + { token: 'viewer-token', action: 'start', expected: 403 }, + { token: 'viewer-token', action: 'stop', expected: 403 }, + { token: 'viewer-token', action: 'restart', expected: 403 }, + ]; + + for (const { token, action, expected } of cases) { + it(`${token.replace('-token', '')} ${action} → ${expected}`, async () => { + const { app } = buildApp(); + const response = await request(app) + .post(`/api/servers/training-server/power/${action}`) + .set(asUser(token)); + expect(response.status).toBe(expected); + if (expected === 403) { + expect(response.body.error.code).toBe('FORBIDDEN'); + } + }); + } + + it('simulated power actions still create activity records', async () => { + const { app, activity } = buildApp(); + await request(app) + .post('/api/servers/training-server/power/restart') + .set(asUser('lead-token')) + .expect(200); + expect(activity).toHaveLength(1); + expect(activity[0]!.action).toBe('server.power.restart'); + }); +}); + +describe('manual log sync and diagnostics', () => { + it('allows only the owner to trigger a manual sync', async () => { + const { app } = buildApp(); + await request(app) + .post('/api/servers/training-server/logs/sync') + .set(asUser('owner-token')) + .expect(200); + for (const token of ['admin-token', 'lead-token', 'viewer-token']) { + const response = await request(app) + .post('/api/servers/training-server/logs/sync') + .set(asUser(token)); + expect(response.status).toBe(403); + } + }); + + it('hides log ingestion health from mission leads and viewers', async () => { + const { app } = buildApp(); + await request(app) + .get('/api/servers/training-server/logs/health') + .set(asUser('admin-token')) + .expect(200); + await request(app) + .get('/api/servers/training-server/logs/health') + .set(asUser('lead-token')) + .expect(403); + }); + + it('restricts user management to the owner', async () => { + const { app } = buildApp(); + const response = await request(app).get('/api/users').set(asUser('viewer-token')); + expect(response.status).toBe(403); + }); +}); + +describe('mod management by role', () => { + it('allows owner and server_admin to update mods', async () => { + const { app } = buildApp(); + for (const token of ['owner-token', 'admin-token']) { + const response = await request(app) + .put('/api/servers/training-server/mods') + .set(asUser(token)) + .send({ mods: [{ modId: '591AF5BDA9F7CE8B', name: 'X' }] }); + expect(response.status).toBe(200); + } + }); + + it('forbids mission leads and viewers from updating mods', async () => { + const { app } = buildApp(); + for (const token of ['lead-token', 'viewer-token']) { + const response = await request(app) + .put('/api/servers/training-server/mods') + .set(asUser(token)) + .send({ mods: [] }); + expect(response.status).toBe(403); + } + }); + + it('rejects invalid mod ids and duplicates', async () => { + const { app } = buildApp(); + const bad = await request(app) + .put('/api/servers/training-server/mods') + .set(asUser('owner-token')) + .send({ mods: [{ modId: 'not-a-mod-id' }] }); + expect(bad.status).toBe(400); + + const dup = await request(app) + .put('/api/servers/training-server/mods') + .set(asUser('owner-token')) + .send({ + mods: [{ modId: '591AF5BDA9F7CE8B' }, { modId: '591af5bda9f7ce8b' }], + }); + expect(dup.status).toBe(400); + }); +}); + +describe('schedule management by role', () => { + it('allows owner and server_admin to view schedules', async () => { + const { app } = buildApp(); + await request(app) + .get('/api/servers/training-server/schedules') + .set(asUser('owner-token')) + .expect(200); + await request(app) + .get('/api/servers/training-server/schedules') + .set(asUser('admin-token')) + .expect(200); + await request(app) + .get('/api/servers/training-server/schedules') + .set(asUser('lead-token')) + .expect(403); + }); + + it('creates restart schedules through the provider and records activity', async () => { + const { app, activity } = buildApp(); + const response = await request(app) + .post('/api/servers/training-server/schedules/restarts') + .set(asUser('admin-token')) + .send({ + name: 'Morning restart', + isActive: true, + minute: 30, + hour: 8, + dayOfWeek: '*', + onlyWhenOnline: true, + }); + expect(response.status).toBe(200); + expect(response.body.schedule.name).toBe('Morning restart'); + expect(response.body.schedule.tasks[0].payload).toBe('restart'); + expect(activity.at(-1)?.action).toBe('schedule.restart.created'); + }); +}); + +describe('performance config by role', () => { + const validBody = { + maxPlayers: 32, + serverMaxViewDistance: null, + networkViewDistance: null, + serverMinGrassDistance: null, + disableThirdPerson: null, + fastValidation: null, + battlEye: null, + aiLimit: null, + playerSaveTime: null, + slotReservationTimeout: null, + lobbyPlayerSynchronise: null, + }; + + it('allows owner and server_admin, forbids mission_lead and viewer', async () => { + const { app } = buildApp(); + for (const token of ['owner-token', 'admin-token']) { + await request(app) + .put('/api/servers/training-server/config/performance') + .set(asUser(token)) + .send(validBody) + .expect(200); + } + for (const token of ['lead-token', 'viewer-token']) { + await request(app) + .put('/api/servers/training-server/config/performance') + .set(asUser(token)) + .send(validBody) + .expect(403); + } + }); + + it('rejects out-of-range values with the offending field named', async () => { + const { app } = buildApp(); + const response = await request(app) + .put('/api/servers/training-server/config/performance') + .set(asUser('owner-token')) + .send({ ...validBody, serverMaxViewDistance: 99999 }); + expect(response.status).toBe(400); + expect(response.body.error.message).toContain('serverMaxViewDistance'); + }); + + it('serves resource history to any authenticated user', async () => { + const { app } = buildApp(); + const response = await request(app) + .get('/api/servers/training-server/resources/history') + .set(asUser('viewer-token')); + expect(response.status).toBe(200); + expect(response.body.intervalSeconds).toBe(15); + }); +}); + +describe('invites', () => { + it('restricts invite management to the owner', async () => { + const { app } = buildApp(); + for (const token of ['admin-token', 'lead-token', 'viewer-token']) { + const response = await request(app).get('/api/invites').set(asUser(token)); + expect(response.status).toBe(403); + } + }); + + it('rejects malformed redeem codes before touching the database', async () => { + const { app } = buildApp(); + const response = await request(app) + .post('/api/invites/redeem') + .set(asUser('viewer-token')) + .send({ code: '' }); + expect(response.status).toBe(400); + }); +}); + +describe('CSRF protection', () => { + it('rejects state-changing requests without the CSRF header', async () => { + const { app } = buildApp(); + const response = await request(app) + .post('/api/servers/training-server/power/restart') + .set('Cookie', 'rp_session=owner-token'); + expect(response.status).toBe(403); + }); + + it('rejects cross-origin state-changing requests', async () => { + const { app } = buildApp(); + const response = await request(app) + .post('/api/servers/training-server/power/restart') + .set(asUser('owner-token')) + .set('Origin', 'https://evil.example.com'); + expect(response.status).toBe(403); + }); +}); diff --git a/apps/api/test/helpers/in-memory-ingestion-store.ts b/apps/api/test/helpers/in-memory-ingestion-store.ts new file mode 100644 index 0000000..b2cc0cd --- /dev/null +++ b/apps/api/test/helpers/in-memory-ingestion-store.ts @@ -0,0 +1,165 @@ +import { randomUUID } from 'node:crypto'; +import type { + CursorRecord, + IngestionStore, + LogSource, + NewServerEvent, + OpenSessionRecord, + PlayerRecord, +} from '../../src/modules/reforger-logs/ingestion/types.js'; +import type { DownloadableFile } from '../../src/modules/pterodactyl/types.js'; + +export type StoredSession = { + id: string; + serverId: string; + playerId: string; + connectedAt: Date; + disconnectedAt: Date | null; + durationSeconds: number | null; + disconnectReason: string | null; + sourceLogPath: string; +}; + +export type StoredEvent = NewServerEvent & { id: string }; + +export class InMemoryIngestionStore implements IngestionStore { + cursors = new Map(); + events: StoredEvent[] = []; + players: (PlayerRecord & { firstSeenAt: Date; lastSeenAt: Date })[] = []; + sessions: StoredSession[] = []; + + async getCursor(serverId: string, logPath: string) { + return this.cursors.get(`${serverId}:${logPath}`) ?? null; + } + + async saveCursor(cursor: CursorRecord) { + this.cursors.set(`${cursor.serverId}:${cursor.logPath}`, { ...cursor }); + } + + async insertEventIfNew(event: NewServerEvent) { + const duplicate = this.events.find( + (e) => + e.serverId === event.serverId && + e.sourceLogPath === event.sourceLogPath && + e.sourceLineHash === event.sourceLineHash, + ); + if (duplicate) return { created: false, eventId: null }; + const stored = { ...event, id: randomUUID() }; + this.events.push(stored); + return { created: true, eventId: stored.id }; + } + + async findPlayerByExternalId(serverId: string, externalPlayerId: string) { + return ( + this.players.find( + (p) => p.serverId === serverId && p.externalPlayerId === externalPlayerId, + ) ?? null + ); + } + + async findPlayerByName(serverId: string, displayName: string) { + return ( + this.players.find((p) => p.serverId === serverId && p.displayName === displayName) ?? null + ); + } + + async createPlayer(input: { + serverId: string; + displayName: string; + externalPlayerId: string | null; + seenAt: Date; + }) { + const player = { + id: randomUUID(), + serverId: input.serverId, + displayName: input.displayName, + externalPlayerId: input.externalPlayerId, + firstSeenAt: input.seenAt, + lastSeenAt: input.seenAt, + }; + this.players.push(player); + return player; + } + + async updatePlayer( + playerId: string, + patch: { externalPlayerId?: string; displayName?: string; lastSeenAt?: Date }, + ) { + const player = this.players.find((p) => p.id === playerId); + if (player) Object.assign(player, patch); + } + + async getOpenSession(serverId: string, playerId: string): Promise { + const session = this.sessions.find( + (s) => s.serverId === serverId && s.playerId === playerId && s.disconnectedAt === null, + ); + return session + ? { id: session.id, playerId: session.playerId, connectedAt: session.connectedAt } + : null; + } + + async openSession(input: { + serverId: string; + playerId: string; + connectedAt: Date; + sourceLogPath: string; + }) { + const session: StoredSession = { + id: randomUUID(), + serverId: input.serverId, + playerId: input.playerId, + connectedAt: input.connectedAt, + disconnectedAt: null, + durationSeconds: null, + disconnectReason: null, + sourceLogPath: input.sourceLogPath, + }; + this.sessions.push(session); + return { id: session.id, playerId: session.playerId, connectedAt: session.connectedAt }; + } + + async closeSession( + sessionId: string, + input: { disconnectedAt: Date; durationSeconds: number; disconnectReason: string | null }, + ) { + const session = this.sessions.find((s) => s.id === sessionId); + if (session) Object.assign(session, input); + } + + async closeAllOpenSessions(serverId: string, disconnectedAt: Date, reason: string) { + const open = this.sessions.filter((s) => s.serverId === serverId && s.disconnectedAt === null); + for (const session of open) { + session.disconnectedAt = disconnectedAt; + session.durationSeconds = Math.max( + 0, + Math.round((disconnectedAt.getTime() - session.connectedAt.getTime()) / 1000), + ); + session.disconnectReason = reason; + } + return { closed: open.length }; + } + + openSessions(serverId: string) { + return this.sessions.filter((s) => s.serverId === serverId && s.disconnectedAt === null); + } +} + +/** LogSource whose content can be mutated between syncs to simulate a live file. */ +export class FakeLogSource implements LogSource { + content = ''; + failWith: Error | null = null; + + async fetchLog(_serverId: string, logPath: string, maxBytes: number): Promise { + if (this.failWith) throw this.failWith; + const buffer = Buffer.from(this.content, 'utf8'); + const trimmed = + buffer.byteLength > maxBytes ? buffer.subarray(buffer.byteLength - maxBytes) : buffer; + return { + path: logPath, + content: trimmed.toString('utf8'), + totalSizeBytes: buffer.byteLength, + contentStartOffset: buffer.byteLength - trimmed.byteLength, + truncated: trimmed.byteLength < buffer.byteLength, + }; + } +} diff --git a/apps/api/test/ingestion-service.test.ts b/apps/api/test/ingestion-service.test.ts new file mode 100644 index 0000000..64e9f26 --- /dev/null +++ b/apps/api/test/ingestion-service.test.ts @@ -0,0 +1,229 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { LogIngestionService } from '../src/modules/reforger-logs/ingestion/ingestion-service.js'; +import { createLogger } from '../src/lib/logger.js'; +import { FakeLogSource, InMemoryIngestionStore } from './helpers/in-memory-ingestion-store.js'; + +const SERVER_ID = 'srv-1'; +const PROVIDER_ID = 'ptero-1'; +const LOG_PATH = '/profile/logs/console.log'; + +const HEADER = 'Log started 2026-07-04 10:00:00\n'; + +function connect(time: string, num: number, name: string): string { + return `${time} DEFAULT : BattlEye Server: 'Player #${num} ${name} (10.0.0.${num}:5000) connected'\n`; +} +function guid(time: string, num: number, name: string, id: string): string { + return `${time} DEFAULT : BattlEye Server: 'Player #${num} ${name} - GUID: ${id}'\n`; +} +function disconnect(time: string, num: number, name: string): string { + return `${time} DEFAULT : BattlEye Server: 'Player #${num} ${name} disconnected'\n`; +} +function kill(time: string, victim: string, killer: string, friendly = false): string { + return `${time} SCRIPT : ServerAdminTools | Event serveradmintools_player_killed | player: ${victim}, instigator: ${killer}, friendly: ${friendly ? 'true' : 'false'}\n`; +} + +describe('LogIngestionService', () => { + let store: InMemoryIngestionStore; + let source: FakeLogSource; + let service: LogIngestionService; + + beforeEach(() => { + store = new InMemoryIngestionStore(); + source = new FakeLogSource(); + service = new LogIngestionService(source, store, createLogger('silent'), { + maxDownloadBytes: 2 * 1024 * 1024, + }); + }); + + const sync = () => service.sync(SERVER_ID, PROVIDER_ID, LOG_PATH); + + it('creates players, events, and open sessions from connect lines', async () => { + source.content = + HEADER + + connect('10:05:00.000', 1, 'Braeden') + + guid('10:05:01.000', 1, 'Braeden', 'aabbccdd11223344'); + const result = await sync(); + + expect(result.createdEvents).toBe(1); + expect(store.players).toHaveLength(1); + expect(store.players[0]!.externalPlayerId).toBe('aabbccdd11223344'); + expect(store.openSessions(SERVER_ID)).toHaveLength(1); + expect(result.cursorReset).toBe(false); + }); + + it('closes sessions with duration on disconnect', async () => { + source.content = + HEADER + connect('10:00:10.000', 1, 'Braeden') + disconnect('10:42:10.000', 1, 'Braeden'); + await sync(); + + expect(store.openSessions(SERVER_ID)).toHaveLength(0); + const session = store.sessions[0]!; + expect(session.durationSeconds).toBe(42 * 60); + }); + + it('tracks multiple simultaneous players', async () => { + source.content = + HEADER + + connect('10:01:00.000', 1, 'Alpha') + + connect('10:02:00.000', 2, 'Bravo') + + connect('10:03:00.000', 3, 'Charlie') + + disconnect('10:30:00.000', 2, 'Bravo'); + await sync(); + + expect(store.players).toHaveLength(3); + const open = store.openSessions(SERVER_ID); + expect(open).toHaveLength(2); + const openNames = open.map((s) => store.players.find((p) => p.id === s.playerId)!.displayName); + expect(openNames.sort()).toEqual(['Alpha', 'Charlie']); + }); + + it('stores ServerAdminTools killfeed events', async () => { + source.content = HEADER + kill('10:10:00.000', 'Victim', 'Killer'); + const result = await sync(); + + expect(result.createdEvents).toBe(1); + expect(store.events[0]!.eventType).toBe('player_killed'); + expect(store.events[0]!.payload).toMatchObject({ + killerName: 'Killer', + victimName: 'Victim', + friendly: false, + distanceMeters: null, + weapon: null, + }); + expect(store.players.map((player) => player.displayName).sort()).toEqual(['Killer', 'Victim']); + }); + + it('does not duplicate events when the same content is synced twice', async () => { + source.content = HEADER + connect('10:05:00.000', 1, 'Braeden'); + await sync(); + // Force a cursor reset by clearing the cursor: same lines get re-read. + store.cursors.clear(); + const second = await sync(); + + expect(second.createdEvents).toBe(0); + expect(store.events).toHaveLength(1); + expect(store.openSessions(SERVER_ID)).toHaveLength(1); + }); + + it('processes only appended content on subsequent syncs', async () => { + source.content = HEADER + connect('10:05:00.000', 1, 'Braeden'); + const first = await sync(); + source.content += disconnect('10:45:00.000', 1, 'Braeden'); + const second = await sync(); + + expect(first.createdEvents).toBe(1); + expect(second.createdEvents).toBe(1); + expect(second.cursorReset).toBe(false); + expect(second.processedLines).toBe(1); + expect(store.openSessions(SERVER_ID)).toHaveLength(0); + }); + + it('carries a partial trailing line across syncs and parses it once complete', async () => { + const full = connect('10:05:00.000', 1, 'Braeden'); + source.content = HEADER + full.slice(0, 40); // mid-line + const first = await sync(); + expect(first.createdEvents).toBe(0); + + source.content = HEADER + full; + const second = await sync(); + expect(second.createdEvents).toBe(1); + expect(store.events[0]!.eventType).toBe('player_connected'); + }); + + it('handles log rotation: resets the cursor and ingests the new file without duplicates', async () => { + source.content = + HEADER + connect('10:05:00.000', 1, 'Braeden') + disconnect('11:00:00.000', 1, 'Braeden'); + await sync(); + expect(store.events).toHaveLength(2); + + // Rotation: much smaller replacement file with a fresh header. + source.content = 'Log started 2026-07-04 11:30:00\n' + connect('11:31:00.000', 1, 'Sable'); + const afterRotation = await sync(); + + expect(afterRotation.cursorReset).toBe(true); + expect(afterRotation.createdEvents).toBe(1); + expect(store.events).toHaveLength(3); + }); + + it('bounds the first sync of a huge file instead of importing full history', async () => { + let old = HEADER; + for (let i = 0; i < 30_000; i += 1) { + old += `09:00:00.000 SCRIPT : filler line ${i} ${'x'.repeat(20)}\n`; + } + source.content = old + connect('10:05:00.000', 1, 'Braeden'); + const result = await sync(); + + expect(result.createdEvents).toBe(1); + expect(result.processedLines).toBeLessThan(30_000); + }); + + it('closes orphaned sessions when a server start is detected', async () => { + source.content = HEADER + connect('10:05:00.000', 1, 'Braeden'); + await sync(); + expect(store.openSessions(SERVER_ID)).toHaveLength(1); + + source.content += '11:00:00.000 DEFAULT : Game successfully created.\n'; + const result = await sync(); + + expect(store.openSessions(SERVER_ID)).toHaveLength(0); + expect(store.sessions[0]!.disconnectReason).toBe('server_restart'); + expect(store.events.map((e) => e.eventType)).toContain('server_restart_detected'); + expect(result.updatedSessions).toBe(1); + }); + + it('closes a stale session when the same player reconnects without a disconnect', async () => { + source.content = HEADER + connect('10:05:00.000', 1, 'Braeden'); + await sync(); + source.content += connect('12:00:00.000', 4, 'Braeden'); + await sync(); + + const open = store.openSessions(SERVER_ID); + expect(open).toHaveLength(1); + expect(open[0]!.connectedAt.toISOString()).toBe('2026-07-04T12:00:00.000Z'); + const closed = store.sessions.find((s) => s.disconnectedAt !== null)!; + expect(closed.disconnectReason).toBe('missed_disconnect'); + }); + + it('records a sanitized error on the cursor when the download fails', async () => { + source.failWith = new Error('connect ETIMEDOUT 10.1.2.3:443 with apiKey=secret123'); + await expect(sync()).rejects.toThrow(); + + const cursor = await store.getCursor(SERVER_ID, LOG_PATH); + expect(cursor?.lastErrorAt).toBeInstanceOf(Date); + expect(cursor?.lastErrorMessage).not.toContain('secret123'); + expect(cursor?.lastSuccessfulSyncAt).toBeNull(); + + // Recovery: the next successful sync clears the error. + source.failWith = null; + source.content = HEADER + connect('10:05:00.000', 1, 'Braeden'); + await sync(); + const recovered = await store.getCursor(SERVER_ID, LOG_PATH); + expect(recovered?.lastErrorAt).toBeNull(); + expect(recovered?.lastSuccessfulSyncAt).toBeInstanceOf(Date); + }); + + it('keeps one player when the logs emit both identityId and BattlEye GUID', async () => { + // Real logs emit BACKEND Authenticated (uuid) then the BE GUID line. + source.content = + HEADER + + '10:04:59.941 BACKEND : Authenticated player: rplIdentity=0x00000000 identityId=33cd5666-3466-477c-aeb8-010df1978756 name=Braeden\n' + + connect('10:05:00.000', 1, 'Braeden') + + guid('10:05:01.000', 1, 'Braeden', '8f1ec46b6979b3a3590e62aa8b757a68'); + await sync(); + + expect(store.players).toHaveLength(1); + // First identity wins; the GUID does not split the player. + expect(store.players[0]!.externalPlayerId).toBe('33cd5666-3466-477c-aeb8-010df1978756'); + expect(store.openSessions(SERVER_ID)).toHaveLength(1); + }); + + it('merges identity lines that arrive in a later sync via open-session name match', async () => { + source.content = HEADER + connect('10:05:00.000', 1, 'Braeden'); + await sync(); + source.content += guid('10:05:02.000', 1, 'Braeden', 'deadbeef00000001'); + await sync(); + + expect(store.players).toHaveLength(1); + expect(store.players[0]!.externalPlayerId).toBe('deadbeef00000001'); + }); +}); diff --git a/apps/api/test/mods-service.test.ts b/apps/api/test/mods-service.test.ts new file mode 100644 index 0000000..057404c --- /dev/null +++ b/apps/api/test/mods-service.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js'; +import { ConfigFileGateway } from '../src/modules/config/config-file-gateway.js'; +import { ServerModsService } from '../src/modules/config/mods-service.js'; +import type { ConfigSyncService } from '../src/modules/config/config-sync.js'; +import type { ServerRecord } from '../src/modules/servers/server-service.js'; +import { createLogger } from '../src/lib/logger.js'; +import { ApiError } from '../src/lib/errors.js'; + +const server: ServerRecord = { + id: 'srv-1', + slug: 'training-server', + name: 'SCAR Operations', + providerType: 'pterodactyl', + pterodactylServerId: 'abc123', + status: 'online', + maxPlayers: 16, + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe('ServerModsService', () => { + let provider: MockGameServerProvider; + let service: ServerModsService; + let configSyncCalled: number; + + beforeEach(() => { + provider = new MockGameServerProvider(); + configSyncCalled = 0; + const configSync = { + sync: async () => { + configSyncCalled += 1; + return { changed: true, revisionVersion: 2, serverName: 'x', maxPlayers: 16 }; + }, + } as unknown as ConfigSyncService; + service = new ServerModsService( + new ConfigFileGateway(provider, '/config.json'), + configSync, + createLogger('silent'), + ); + }); + + it('reads the current mods from config.json', async () => { + const result = await service.getMods(server); + expect(result.mods).toEqual([ + { modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' }, + ]); + }); + + it('writes the new mod list while preserving every other config field', async () => { + const result = await service.setMods(server, [ + { modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' }, + { modId: '5AAF0CCE3F001FB5', name: 'Server Admin Tools' }, + ]); + + expect(result.added).toBe(1); + expect(result.removed).toBe(0); + expect(result.requiresRestart).toBe(true); + expect(result.mods).toHaveLength(2); + + const written = provider.writtenFiles.get('/config.json')!; + const parsed = JSON.parse(written); + // game.mods replaced… + expect(parsed.game.mods).toEqual([ + { modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' }, + { modId: '5AAF0CCE3F001FB5', name: 'Server Admin Tools' }, + ]); + // …everything else untouched. + expect(parsed.bindPort).toBe(2001); + expect(parsed.game.name).toBe('Mock Reforger Server'); + expect(parsed.game.maxPlayers).toBe(16); + expect(parsed.operating.aiLimit).toBe(40); + }); + + it('writes a rollback backup of the previous file before modifying it', async () => { + const before = (await provider.downloadTextFile('abc123', '/config.json')).content; + await service.setMods(server, []); + expect(provider.writtenFiles.get('/config.json.bak')).toBe(before); + // Removal reflected in the live file. + const after = JSON.parse(provider.writtenFiles.get('/config.json')!); + expect(after.game.mods).toEqual([]); + }); + + it('imports a config revision after a successful write', async () => { + await service.setMods(server, []); + expect(configSyncCalled).toBe(1); + }); + + it('normalizes mod ids to uppercase and drops empty name/version', async () => { + const result = await service.setMods(server, [{ modId: '69c566706abd5a3c', name: '' }]); + expect(result.mods).toEqual([{ modId: '69C566706ABD5A3C' }]); + }); + + it('fails the write when read-back verification does not match', async () => { + // Simulate a server that ignores writes to config.json. + const originalWrite = provider.writeTextFile.bind(provider); + vi.spyOn(provider, 'writeTextFile').mockImplementation(async (sid, path, content) => { + if (path === '/config.json') return; // swallow the write + await originalWrite(sid, path, content); + }); + await expect(service.setMods(server, [])).rejects.toThrow(/verification failed/); + }); + + it('refuses to modify a config without a game section', async () => { + await provider.writeTextFile('abc123', '/config.json', '{"something": true}'); + await expect(service.setMods(server, [])).rejects.toThrow(ApiError); + }); +}); diff --git a/apps/api/test/performance-service.test.ts b/apps/api/test/performance-service.test.ts new file mode 100644 index 0000000..a0f17d6 --- /dev/null +++ b/apps/api/test/performance-service.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js'; +import { ConfigFileGateway } from '../src/modules/config/config-file-gateway.js'; +import { PerformanceSettingsService } from '../src/modules/config/performance-service.js'; +import type { ConfigSyncService } from '../src/modules/config/config-sync.js'; +import type { ServerRecord } from '../src/modules/servers/server-service.js'; +import { createLogger } from '../src/lib/logger.js'; + +const server: ServerRecord = { + id: 'srv-1', + slug: 'training-server', + name: 'SCAR Operations', + providerType: 'pterodactyl', + pterodactylServerId: 'abc123', + status: 'online', + maxPlayers: 16, + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe('PerformanceSettingsService', () => { + let provider: MockGameServerProvider; + let service: PerformanceSettingsService; + + beforeEach(() => { + provider = new MockGameServerProvider(); + const configSync = { sync: async () => ({}) } as unknown as ConfigSyncService; + service = new PerformanceSettingsService( + new ConfigFileGateway(provider, '/config.json'), + configSync, + createLogger('silent'), + ); + }); + + it('reads current values, reporting absent keys as null', async () => { + const { settings } = await service.get(server); + // Present in the mock config.json: + expect(settings.maxPlayers).toBe(16); + expect(settings.serverMaxViewDistance).toBe(2500); + expect(settings.aiLimit).toBe(40); + expect(settings.disableThirdPerson).toBe(false); + // Absent keys: + expect(settings.playerSaveTime).toBeNull(); + expect(settings.fastValidation).toBeNull(); + }); + + it('sets changed values and removes nulled keys, preserving everything else', async () => { + const { settings } = await service.get(server); + const result = await service.update(server, { + ...settings, + maxPlayers: 32, + playerSaveTime: 180, // new key + aiLimit: null, // remove key → game default + }); + + expect(result.changedFields.sort()).toEqual(['aiLimit', 'maxPlayers', 'playerSaveTime']); + expect(result.requiresRestart).toBe(true); + + const written = JSON.parse(provider.writtenFiles.get('/config.json')!); + expect(written.game.maxPlayers).toBe(32); + expect(written.operating.playerSaveTime).toBe(180); + expect('aiLimit' in written.operating).toBe(false); + // Untouched fields preserved: + expect(written.bindPort).toBe(2001); + expect(written.game.scenarioId).toContain('Missions'); + expect(written.game.mods).toHaveLength(1); + // Backup written: + expect(provider.writtenFiles.get('/config.json.bak')).toBeTruthy(); + }); + + it('does not write the file at all when nothing changed', async () => { + const { settings } = await service.get(server); + const result = await service.update(server, settings); + expect(result.changedFields).toEqual([]); + expect(provider.writtenFiles.has('/config.json')).toBe(false); + }); +}); diff --git a/apps/api/test/providers.test.ts b/apps/api/test/providers.test.ts new file mode 100644 index 0000000..a24f261 --- /dev/null +++ b/apps/api/test/providers.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest'; +import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js'; +import { PterodactylProvider } from '../src/modules/pterodactyl/pterodactyl-provider.js'; +import { ApiError } from '../src/lib/errors.js'; + +const API_KEY = 'ptlc_super_secret_key_123'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('MockGameServerProvider', () => { + it('reports online with plausible resources by default', async () => { + const provider = new MockGameServerProvider(); + expect(await provider.getServerStatus()).toBe('online'); + const resources = await provider.getServerResources(); + expect(resources.status).toBe('online'); + expect(resources.memoryLimitBytes).toBeGreaterThan(0); + expect(resources.uptimeMs).toBeGreaterThan(0); + provider.dispose(); + }); + + it('transitions through stopping on stop', async () => { + const provider = new MockGameServerProvider(); + await provider.stopServer(); + expect(await provider.getServerStatus()).toBe('stopping'); + provider.dispose(); + }); + + it('serves a parseable console.log fixture', async () => { + const provider = new MockGameServerProvider(); + const file = await provider.downloadTextFile('any', '/profile/logs/console.log'); + expect(file.content).toContain('connected'); + expect(file.content).toContain('Log started'); + expect(file.contentStartOffset).toBe(0); + provider.dispose(); + }); + + it('rejects unknown paths instead of exposing a file system', async () => { + const provider = new MockGameServerProvider(); + await expect(provider.downloadTextFile('any', '/etc/passwd')).rejects.toThrow(ApiError); + provider.dispose(); + }); +}); + +describe('PterodactylProvider', () => { + it('maps resource responses from the Client API', async () => { + const fetchImpl = vi.fn(async (url: string | URL, _init?: RequestInit) => { + const path = String(url); + if (path.endsWith('/resources')) { + return jsonResponse({ + object: 'stats', + attributes: { + current_state: 'running', + resources: { + memory_bytes: 1024, + cpu_absolute: 51.5, + disk_bytes: 2048, + network_rx_bytes: 10, + network_tx_bytes: 20, + uptime: 5000, + }, + }, + }); + } + return jsonResponse({ attributes: { limits: { cpu: 400, memory: 8192, disk: 40960 } } }); + }); + const provider = new PterodactylProvider({ + baseUrl: 'https://panel.example.com', + apiKey: API_KEY, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + const resources = await provider.getServerResources('abc123'); + expect(resources.status).toBe('online'); + expect(resources.cpuPercent).toBe(51.5); + expect(resources.cpuLimitPercent).toBe(400); + expect(resources.memoryLimitBytes).toBe(8192 * 1024 * 1024); + + const [calledUrl, calledInit] = fetchImpl.mock.calls[0]!; + expect(String(calledUrl)).toBe('https://panel.example.com/api/client/servers/abc123/resources'); + const headers = calledInit?.headers as Record; + expect(headers.Authorization).toBe(`Bearer ${API_KEY}`); + }); + + it('sends power signals with the expected body', async () => { + const fetchImpl = vi.fn(async (_url: string | URL, _init?: RequestInit) => { + return new Response(null, { status: 204 }); + }); + const provider = new PterodactylProvider({ + baseUrl: 'https://panel.example.com', + apiKey: API_KEY, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + await provider.restartServer('abc123'); + const [url, init] = fetchImpl.mock.calls[0]!; + expect(String(url)).toContain('/power'); + expect(init?.method).toBe('POST'); + expect(JSON.parse(String(init?.body))).toEqual({ signal: 'restart' }); + }); + + it('maps HTTP errors without leaking the API key or full URL', async () => { + const fetchImpl = vi.fn(async () => new Response('nope', { status: 500 })); + const provider = new PterodactylProvider({ + baseUrl: 'https://panel.example.com', + apiKey: API_KEY, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + const error = await provider.getServerResources('abc123').catch((e: unknown) => e as ApiError); + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).code).toBe('UPSTREAM_UNAVAILABLE'); + expect((error as ApiError).message).not.toContain(API_KEY); + expect((error as ApiError).message).not.toContain('panel.example.com'); + expect((error as ApiError).message).toContain('500'); + }); + + it('maps timeouts to a sanitized upstream error', async () => { + const timeoutError = new Error('The operation was aborted due to timeout'); + timeoutError.name = 'TimeoutError'; + const fetchImpl = vi.fn(async () => { + throw timeoutError; + }); + const provider = new PterodactylProvider({ + baseUrl: 'https://panel.example.com', + apiKey: API_KEY, + fetchImpl: fetchImpl as unknown as typeof fetch, + timeoutMs: 50, + }); + const error = await provider.getServerStatus('abc123').catch((e: unknown) => e as ApiError); + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).message).toContain('timed out'); + expect((error as ApiError).message).not.toContain(API_KEY); + }); +}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..6b6c434 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"] + }, + "include": ["src", "test", "tsup.config.ts", "drizzle.config.ts", "vitest.config.ts"] +} diff --git a/apps/api/tsup.config.ts b/apps/api/tsup.config.ts new file mode 100644 index 0000000..e48e5f8 --- /dev/null +++ b/apps/api/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts', 'src/db/migrate.ts', 'src/db/seed.ts'], + format: ['esm'], + target: 'node22', + sourcemap: true, + clean: true, + // Bundle the source-only workspace package into the output. + noExternal: ['@reforger-panel/shared'], +}); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..960f453 --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts', 'test/**/*.test.ts'], + environment: 'node', + }, +}); diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..ba121af --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + Reforger Panel + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..45f1442 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,28 @@ +{ + "name": "@reforger-panel/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@reforger-panel/shared": "*", + "@tanstack/react-query": "^5.80.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.6.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.5.0", + "tailwindcss": "^4.1.0", + "typescript": "^5.8.0", + "vite": "^6.3.0" + } +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..b511061 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,89 @@ +import { useEffect } from 'react'; +import { QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/react-query'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; +import { useCurrentUser } from './api/hooks.js'; +import { api, ApiClientError } from './api/client.js'; +import { Layout } from './components/layout.js'; +import { Spinner } from './components/ui.js'; +import { LoginPage } from './pages/login.js'; +import { OverviewPage } from './pages/overview.js'; +import { ModsPage } from './pages/mods.js'; +import { LogsPage } from './pages/logs.js'; +import { + ActivityPage, + ConfigurationsPage, + KillfeedPage, + PlayersPage, + SettingsPage, +} from './pages/simple-pages.js'; + +const queryClient = new QueryClient(); + +/** Redeems a stored invite code once, right after login, then refreshes /me. */ +function InviteRedeemer() { + const client = useQueryClient(); + useEffect(() => { + const code = localStorage.getItem('rp_invite'); + if (!code) return; + localStorage.removeItem('rp_invite'); + void api + .post('/api/invites/redeem', { code }) + .then(() => client.invalidateQueries({ queryKey: ['auth', 'me'] })) + .catch(() => undefined); // invalid/expired codes fail quietly + }, [client]); + return null; +} + +function AuthGate() { + const { data: user, isLoading, error } = useCurrentUser(); + + if (isLoading) { + return ( +
+ +
+ ); + } + if (error instanceof ApiClientError && error.status === 401) { + return ; + } + if (!user) { + return ( +
+ Could not reach the panel API. Is the backend running? +
+ ); + } + + return ( + <> + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* Old bookmarks from the tabbed server page and plural path. */} + } /> + } /> + } /> + + + + ); +} + +export function App() { + return ( + + + + + + ); +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts new file mode 100644 index 0000000..68938b0 --- /dev/null +++ b/apps/web/src/api/client.ts @@ -0,0 +1,55 @@ +import type { ApiErrorBody } from '@reforger-panel/shared'; + +export class ApiClientError extends Error { + readonly code: string; + readonly status: number; + + constructor(status: number, code: string, message: string) { + super(message); + this.code = code; + this.status = status; + } +} + +async function request(path: string, init: RequestInit = {}): Promise { + const method = init.method ?? 'GET'; + const headers: Record = { ...(init.headers as Record) }; + if (method !== 'GET' && method !== 'HEAD') { + headers['X-CSRF-Protection'] = '1'; + if (init.body) headers['Content-Type'] = 'application/json'; + } + const response = await fetch(path, { ...init, method, headers, credentials: 'same-origin' }); + if (!response.ok) { + let code = 'INTERNAL_ERROR'; + let message = `Request failed (${response.status})`; + try { + const body = (await response.json()) as ApiErrorBody; + code = body.error.code; + message = body.error.message; + } catch { + // non-JSON error body + } + throw new ApiClientError(response.status, code, message); + } + return (await response.json()) as T; +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, body?: unknown) => + request(path, { + method: 'POST', + body: body === undefined ? undefined : JSON.stringify(body), + }), + put: (path: string, body?: unknown) => + request(path, { + method: 'PUT', + body: body === undefined ? undefined : JSON.stringify(body), + }), + patch: (path: string, body?: unknown) => + request(path, { + method: 'PATCH', + body: body === undefined ? undefined : JSON.stringify(body), + }), + delete: (path: string) => request(path, { method: 'DELETE' }), +}; diff --git a/apps/web/src/api/hooks.ts b/apps/web/src/api/hooks.ts new file mode 100644 index 0000000..9bc32e4 --- /dev/null +++ b/apps/web/src/api/hooks.ts @@ -0,0 +1,371 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { + ActivityItem, + ConfigurationResponse, + CurrentUser, + InviteSummary, + KillfeedEvent, + MissionsResponse, + PerformanceSettingsPatch, + PerformanceSettingsResponse, + RawLogsResponse, + RestartScheduleInput, + ResourceHistoryResponse, + StartupResponse, + KnownPlayer, + LogIngestionHealth, + LogSyncResult, + ModPackSummary, + PanelUser, + PlayersResponse, + ReforgerConfigMod, + ServerModsResponse, + UpdateModsResult, + ServerResources, + ServerScheduleSummary, + ServerSummary, + WorkshopHealth, + WorkshopModDetail, + WorkshopSearchResponse, +} from '@reforger-panel/shared'; +import { api, ApiClientError } from './client.js'; + +export function useCurrentUser() { + return useQuery({ + queryKey: ['auth', 'me'], + queryFn: () => api.get('/api/auth/me'), + retry: (failureCount, error) => + !(error instanceof ApiClientError && error.status === 401) && failureCount < 2, + staleTime: 60_000, + }); +} + +export function useLogout() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => api.post('/api/auth/logout'), + onSuccess: () => queryClient.clear(), + }); +} + +export function useServers() { + return useQuery({ + queryKey: ['servers'], + queryFn: () => api.get<{ servers: ServerSummary[] }>('/api/servers'), + refetchInterval: 15_000, + }); +} + +export function useServer(slug: string) { + return useQuery({ + queryKey: ['servers', slug], + queryFn: () => api.get(`/api/servers/${slug}`), + refetchInterval: 15_000, + }); +} + +export function useServerResources(slug: string, enabled = true) { + return useQuery({ + queryKey: ['servers', slug, 'resources'], + queryFn: () => api.get(`/api/servers/${slug}/resources`), + refetchInterval: 10_000, + enabled, + }); +} + +export function usePlayers(slug: string) { + return useQuery({ + queryKey: ['servers', slug, 'players'], + queryFn: () => api.get(`/api/servers/${slug}/players`), + refetchInterval: 15_000, + }); +} + +export function useKnownPlayers(slug: string) { + return useQuery({ + queryKey: ['servers', slug, 'players', 'known'], + queryFn: () => api.get<{ players: KnownPlayer[] }>(`/api/servers/${slug}/players/known`), + refetchInterval: 30_000, + }); +} + +export function useActivity(slug: string, limit = 50) { + return useQuery({ + queryKey: ['servers', slug, 'activity', limit], + queryFn: () => + api.get<{ activity: ActivityItem[] }>(`/api/servers/${slug}/activity?limit=${limit}`), + refetchInterval: 20_000, + }); +} + +export function useKillfeed(slug: string, limit = 100) { + return useQuery({ + queryKey: ['servers', slug, 'killfeed', limit], + queryFn: () => + api.get<{ events: KillfeedEvent[] }>(`/api/servers/${slug}/killfeed?limit=${limit}`), + refetchInterval: 10_000, + }); +} + +export function useConfiguration(slug: string) { + return useQuery({ + queryKey: ['servers', slug, 'configuration'], + queryFn: () => api.get(`/api/servers/${slug}/configuration`), + // Live download from the game server on each fetch — keep it calm. + staleTime: 60_000, + refetchOnWindowFocus: false, + }); +} + +export function useMissions(slug: string) { + return useQuery({ + queryKey: ['servers', slug, 'missions'], + queryFn: () => api.get(`/api/servers/${slug}/missions`), + staleTime: 5 * 60_000, + refetchOnWindowFocus: false, + }); +} + +export function useRawLogs(slug: string, lines: number, autoRefresh: boolean, enabled: boolean) { + return useQuery({ + queryKey: ['servers', slug, 'logs', 'raw', lines], + queryFn: () => api.get(`/api/servers/${slug}/logs/raw?lines=${lines}`), + refetchInterval: autoRefresh ? 10_000 : false, + enabled, + }); +} + +export function useStartupVariables(slug: string, enabled: boolean) { + return useQuery({ + queryKey: ['servers', slug, 'startup'], + queryFn: () => api.get(`/api/servers/${slug}/startup`), + staleTime: 60_000, + refetchOnWindowFocus: false, + enabled, + }); +} + +export function useUpdateStartupVariable(slug: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: { key: string; value: string }) => + api.put<{ ok: boolean; requiresRestart: boolean }>( + `/api/servers/${slug}/startup/variable`, + input, + ), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['servers', slug, 'startup'] }), + }); +} + +export function useModPacks(slug: string) { + return useQuery({ + queryKey: ['servers', slug, 'mod-packs'], + queryFn: () => api.get<{ modPacks: ModPackSummary[] }>(`/api/servers/${slug}/mod-packs`), + }); +} + +export function useLogHealth(slug: string, enabled: boolean) { + return useQuery({ + queryKey: ['servers', slug, 'logs', 'health'], + queryFn: () => api.get(`/api/servers/${slug}/logs/health`), + refetchInterval: 20_000, + enabled, + }); +} + +export function usePowerAction(slug: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (action: 'start' | 'stop' | 'restart') => + api.post<{ ok: boolean; simulated: boolean }>(`/api/servers/${slug}/power/${action}`), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['servers'] }); + }, + }); +} + +export function useResourceHistory(slug: string) { + return useQuery({ + queryKey: ['servers', slug, 'resources', 'history'], + queryFn: () => api.get(`/api/servers/${slug}/resources/history`), + refetchInterval: 15_000, + }); +} + +export function usePerformanceSettings(slug: string) { + return useQuery({ + queryKey: ['servers', slug, 'config', 'performance'], + queryFn: () => api.get(`/api/servers/${slug}/config/performance`), + staleTime: 60_000, + refetchOnWindowFocus: false, + }); +} + +export function useSetPerformanceSettings(slug: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (settings: PerformanceSettingsPatch) => + api.put( + `/api/servers/${slug}/config/performance`, + settings, + ), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['servers', slug] }); + }, + }); +} + +export function useInvites(enabled: boolean) { + return useQuery({ + queryKey: ['invites'], + queryFn: () => api.get<{ invites: InviteSummary[] }>('/api/invites'), + enabled, + }); +} + +export function useCreateInvite() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: { role: string; expiresInHours?: number | null }) => + api.post<{ id: string; code: string; role: string; expiresAt: string }>( + '/api/invites', + input, + ), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['invites'] }), + }); +} + +export function useDeleteInvite() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.delete(`/api/invites/${id}`), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['invites'] }), + }); +} + +export function useServerMods(slug: string) { + return useQuery({ + queryKey: ['servers', slug, 'mods'], + queryFn: () => api.get(`/api/servers/${slug}/mods`), + // Each call downloads config.json from Pterodactyl — no background polling. + staleTime: 60_000, + refetchOnWindowFocus: false, + }); +} + +export function useSetServerMods(slug: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (mods: ReforgerConfigMod[]) => + api.put(`/api/servers/${slug}/mods`, { mods }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['servers', slug] }); + }, + }); +} + +export function useManualLogSync(slug: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => api.post(`/api/servers/${slug}/logs/sync`), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['servers', slug] }); + }, + }); +} + +export function useServerSchedules(slug: string, enabled: boolean) { + return useQuery({ + queryKey: ['servers', slug, 'schedules'], + queryFn: () => + api.get<{ schedules: ServerScheduleSummary[]; fetchedAt: string }>( + `/api/servers/${slug}/schedules`, + ), + enabled, + staleTime: 30_000, + }); +} + +export function useCreateRestartSchedule(slug: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: RestartScheduleInput) => + api.post<{ schedule: ServerScheduleSummary }>( + `/api/servers/${slug}/schedules/restarts`, + input, + ), + onSuccess: () => + void queryClient.invalidateQueries({ queryKey: ['servers', slug, 'schedules'] }), + }); +} + +export function useUpdateRestartSchedule(slug: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, input }: { id: string; input: RestartScheduleInput }) => + api.put<{ schedule: ServerScheduleSummary }>( + `/api/servers/${slug}/schedules/${id}/restart`, + input, + ), + onSuccess: () => + void queryClient.invalidateQueries({ queryKey: ['servers', slug, 'schedules'] }), + }); +} + +export function useDeleteSchedule(slug: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.delete(`/api/servers/${slug}/schedules/${id}`), + onSuccess: () => + void queryClient.invalidateQueries({ queryKey: ['servers', slug, 'schedules'] }), + }); +} + +export function useWorkshopHealth() { + return useQuery({ + queryKey: ['workshop', 'health'], + queryFn: () => api.get('/api/workshop/health'), + refetchInterval: 60_000, + }); +} + +export function useWorkshopSearch(query: string, page: number, sort?: string) { + return useQuery({ + queryKey: ['workshop', 'search', query, page, sort], + queryFn: () => + api.get( + `/api/workshop/search?q=${encodeURIComponent(query)}&page=${page}${ + sort ? `&sort=${encodeURIComponent(sort)}` : '' + }`, + ), + // An empty query browses the Workshop front page (/v1/mods). + placeholderData: (previous) => previous, + staleTime: 5 * 60_000, + }); +} + +export function useWorkshopMod(modId: string | null) { + return useQuery({ + queryKey: ['workshop', 'mod', modId], + queryFn: () => api.get(`/api/workshop/mods/${modId}`), + enabled: modId !== null, + staleTime: 5 * 60_000, + }); +} + +export function useUsers(enabled: boolean) { + return useQuery({ + queryKey: ['users'], + queryFn: () => api.get<{ users: PanelUser[] }>('/api/users'), + enabled, + }); +} + +export function useSetUserRole() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ userId, role }: { userId: string; role: string }) => + api.patch(`/api/users/${userId}/role`, { role }), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['users'] }), + }); +} diff --git a/apps/web/src/components/charts.tsx b/apps/web/src/components/charts.tsx new file mode 100644 index 0000000..911a3b9 --- /dev/null +++ b/apps/web/src/components/charts.tsx @@ -0,0 +1,81 @@ +export type ChartSeries = { + points: { t: number; v: number }[]; + /** Any CSS color; used for the line and (when filled) the area. */ + color: string; + fill?: boolean; + label?: string; +}; + +/** + * Dependency-free SVG time-series chart. Series share the x (time) axis and a + * single y scale (`max` fixes it, e.g. 100 for CPU%; otherwise it fits data). + */ +export function TimeSeriesChart({ + series, + max, + height = 64, + className = '', +}: { + series: ChartSeries[]; + max?: number | null; + height?: number; + className?: string; +}) { + const allPoints = series.flatMap((s) => s.points); + if (allPoints.length < 2) { + return ( +
+ collecting data… +
+ ); + } + + const tMin = Math.min(...allPoints.map((p) => p.t)); + const tMax = Math.max(...allPoints.map((p) => p.t)); + const dataMax = Math.max(...allPoints.map((p) => p.v), 0); + const scale = max && max > 0 ? max : dataMax > 0 ? dataMax * 1.15 : 1; + const tSpan = Math.max(1, tMax - tMin); + + const W = 100; + const H = 40; + const x = (t: number) => ((t - tMin) / tSpan) * W; + const y = (v: number) => H - Math.min(1, Math.max(0, v / scale)) * H; + + return ( + + {/* 50% guide line */} + + {series.map((s, index) => { + if (s.points.length < 2) return null; + const line = s.points + .map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.t).toFixed(2)},${y(p.v).toFixed(2)}`) + .join(' '); + const first = s.points[0]!; + const last = s.points[s.points.length - 1]!; + const area = `${line} L${x(last.t).toFixed(2)},${H} L${x(first.t).toFixed(2)},${H} Z`; + return ( + + {s.fill !== false && } + + + ); + })} + + ); +} diff --git a/apps/web/src/components/invites-card.tsx b/apps/web/src/components/invites-card.tsx new file mode 100644 index 0000000..3d73520 --- /dev/null +++ b/apps/web/src/components/invites-card.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react'; +import type { Role } from '@reforger-panel/shared'; +import { ROLE_LABELS } from '@reforger-panel/shared'; +import { useCreateInvite, useDeleteInvite, useInvites } from '../api/hooks.js'; +import { formatDateTime, formatRelativeTime } from '../lib/format.js'; +import { Button, Card, EmptyState, RoleBadge, Spinner } from './ui.js'; + +const INVITABLE_ROLES: Role[] = ['server_admin', 'mission_lead', 'viewer']; +const INVITE_DURATIONS = [ + { label: 'Never expires', value: 'never', hours: null }, + { label: '7 days', value: '168', hours: 168 }, + { label: '30 days', value: '720', hours: 720 }, +] as const; + +function inviteLink(code: string): string { + return `${window.location.origin}/?invite=${code}`; +} + +function isEffectivelyPermanent(expiresAt: string): boolean { + return new Date(expiresAt).getTime() - Date.now() > 20 * 365 * 24 * 60 * 60 * 1000; +} + +export function InvitesCard() { + const { data, isLoading } = useInvites(true); + const createInvite = useCreateInvite(); + const deleteInvite = useDeleteInvite(); + const [role, setRole] = useState('mission_lead'); + const [duration, setDuration] = useState<(typeof INVITE_DURATIONS)[number]['value']>('never'); + const [copied, setCopied] = useState(null); + + const copy = async (code: string) => { + try { + await navigator.clipboard.writeText(inviteLink(code)); + setCopied(code); + setTimeout(() => setCopied(null), 2000); + } catch { + setCopied(null); + } + }; + + return ( + + + + + + } + > + {isLoading || !data ? ( + + ) : data.invites.length === 0 ? ( + + ) : ( +
    + {data.invites.map((invite) => { + const permanent = isEffectivelyPermanent(invite.expiresAt); + const expired = !permanent && new Date(invite.expiresAt).getTime() < Date.now(); + const state = invite.usedAt ? 'used' : expired ? 'expired' : 'active'; + return ( +
  • +
    +

    + {invite.code} + + {state === 'active' && active} + {state === 'used' && ( + + used by {invite.usedBy} {formatRelativeTime(invite.usedAt)} + + )} + {state === 'expired' && expired} +

    +

    + {permanent ? 'never expires' : `expires ${formatDateTime(invite.expiresAt)}`} · + created by {invite.createdBy ?? '—'} +

    +
    +
    + {state === 'active' && ( + + )} + +
    +
  • + ); + })} +
+ )} +

+ Invite links are single-use and grant the selected role at login. Redeemed roles persist + until you change them under Users & roles. +

+
+ ); +} diff --git a/apps/web/src/components/layout.tsx b/apps/web/src/components/layout.tsx new file mode 100644 index 0000000..5e37bce --- /dev/null +++ b/apps/web/src/components/layout.tsx @@ -0,0 +1,140 @@ +import { useState } from 'react'; +import { NavLink, Outlet } from 'react-router-dom'; +import type { Capability, CurrentUser } from '@reforger-panel/shared'; +import { useLogout, useServers } from '../api/hooks.js'; +import { RoleBadge, StatusBadge } from './ui.js'; +import { PowerControls } from './widgets.js'; + +const NAV_ITEMS: { + to: string; + label: string; + exact?: boolean; + capability?: Capability; +}[] = [ + { to: '/', label: 'Overview', exact: true }, + { to: '/mods', label: 'Mods' }, + { to: '/configuration', label: 'Configuration' }, + { to: '/players', label: 'Players' }, + { to: '/killfeed', label: 'Killfeed' }, + { to: '/activity', label: 'Activity' }, + { to: '/logs', label: 'Logs', capability: 'ops.health.view' }, + { to: '/settings', label: 'Settings' }, +]; + +export function Layout({ user }: { user: CurrentUser }) { + const logout = useLogout(); + const { data: serversData } = useServers(); + const server = serversData?.servers[0]; + const [navOpen, setNavOpen] = useState(false); + + return ( +
+ {navOpen && ( +
setNavOpen(false)} + className="fixed inset-0 z-20 bg-black/60 backdrop-blur-sm lg:hidden" + /> + )} + + +
+
+ + {server ? ( +
+
+

Server

+

{server.name}

+
+ + + {server.onlinePlayerCount} / {server.maxPlayers ?? '—'} players + +
+ ) : ( +
+ )} + {server && } +
+
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/mission-card.tsx b/apps/web/src/components/mission-card.tsx new file mode 100644 index 0000000..5a4c97c --- /dev/null +++ b/apps/web/src/components/mission-card.tsx @@ -0,0 +1,108 @@ +import { useState } from 'react'; +import { useConfiguration, useMissions, useSetPerformanceSettings } from '../api/hooks.js'; +import { Button, Card, Spinner } from './ui.js'; +import { shortScenario } from './widgets.js'; + +function missionSourceLabel(source: string): string { + if (source === 'official') return ''; + if (source.startsWith('mod: ')) return `Mod: ${source.slice(5)}`; + return source; +} + +/** + * Mission switcher. Options come from the scenario listing the server prints + * at boot (requires the -listScenarios launch flag, standard on Reforger eggs). + */ +export function MissionCard({ slug, canEdit }: { slug: string; canEdit: boolean }) { + const { data: config, refetch } = useConfiguration(slug); + const { data: missions } = useMissions(slug); + const save = useSetPerformanceSettings(slug); + const [selected, setSelected] = useState(null); + const [message, setMessage] = useState(null); + + if (!config) { + return ( + + + + ); + } + + const current = config.config.scenarioId; + const currentName = + missions?.missions.find((m) => m.scenarioId === current)?.name ?? shortScenario(current); + const value = selected ?? current; + const dirty = value !== current; + + const submit = () => { + setMessage(null); + save.mutate( + { scenarioId: value }, + { + onSuccess: () => { + setSelected(null); + setMessage('Mission saved to config.json — restart the server to switch.'); + void refetch(); + }, + onError: (error) => setMessage(error.message), + }, + ); + }; + + return ( + + + +
+ ) + } + > +
+
+

{currentName}

+

+ {shortScenario(current)} +

+
+ {canEdit && + (missions && missions.missions.length > 0 ? ( + + ) : ( +

+ No scenario listing found in the current log — make sure the server runs with + -listScenarios and has booted recently. +

+ ))} +
+ {message &&

{message}

} + + ); +} diff --git a/apps/web/src/components/performance-form.tsx b/apps/web/src/components/performance-form.tsx new file mode 100644 index 0000000..15b7dd7 --- /dev/null +++ b/apps/web/src/components/performance-form.tsx @@ -0,0 +1,211 @@ +import { useEffect, useState } from 'react'; +import type { PerformanceSettings } from '@reforger-panel/shared'; +import { usePerformanceSettings, useSetPerformanceSettings } from '../api/hooks.js'; +import { Button, Card, Spinner } from './ui.js'; + +type NumberKey = { + [K in keyof PerformanceSettings]: PerformanceSettings[K] extends number | null ? K : never; +}[keyof PerformanceSettings]; +type BooleanKey = Exclude; + +// Ranges/defaults from the Bohemia server-config reference. Blank fields are +// omitted from config.json so the game default applies. +// maxPlayers is deliberately absent: it is controlled via the MAX_PLAYERS +// startup variable to avoid two "max players" inputs on one page. +const NUMBER_FIELDS: { key: NumberKey; label: string; min: number; max: number; hint: string }[] = [ + { + key: 'serverMaxViewDistance', + label: 'Server view distance (m)', + min: 500, + max: 10000, + hint: 'default 1600', + }, + { + key: 'networkViewDistance', + label: 'Network view distance (m)', + min: 500, + max: 5000, + hint: 'default 1500', + }, + { + key: 'serverMinGrassDistance', + label: 'Min grass distance (m)', + min: 0, + max: 150, + hint: '0 = client choice', + }, + { key: 'aiLimit', label: 'AI limit', min: -1, max: 1000, hint: '-1 = unlimited' }, + { + key: 'playerSaveTime', + label: 'Player save interval (s)', + min: 1, + max: 3600, + hint: 'default 120', + }, + { + key: 'slotReservationTimeout', + label: 'Slot reservation timeout (s)', + min: 5, + max: 300, + hint: 'default 60', + }, +]; + +const BOOLEAN_FIELDS: { key: BooleanKey; label: string; hint: string }[] = [ + { key: 'disableThirdPerson', label: 'Disable third person', hint: 'default disabled' }, + { key: 'fastValidation', label: 'Fast validation', hint: 'default enabled' }, + { key: 'battlEye', label: 'BattlEye', hint: 'default enabled' }, + { key: 'lobbyPlayerSynchronise', label: 'Lobby player sync', hint: 'default enabled' }, +]; + +type FormState = Record; + +function toFormState(settings: PerformanceSettings): FormState { + const state: FormState = {}; + for (const field of NUMBER_FIELDS) { + const value = settings[field.key]; + state[field.key] = value === null ? '' : String(value); + } + for (const field of BOOLEAN_FIELDS) { + const value = settings[field.key]; + state[field.key] = value === null ? '' : String(value); + } + return state; +} + +export function PerformanceForm({ slug, canEdit }: { slug: string; canEdit: boolean }) { + const { data, isLoading, error: loadError } = usePerformanceSettings(slug); + const save = useSetPerformanceSettings(slug); + const [form, setForm] = useState(null); + const [message, setMessage] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); + + useEffect(() => { + if (data && form === null) setForm(toFormState(data.settings)); + }, [data, form]); + + if (isLoading || (!form && !loadError)) return ; + if (loadError) return

{loadError.message}

; + if (!form || !data) return null; + + const baseline = toFormState(data.settings); + const dirty = Object.keys(form).some((key) => form[key] !== baseline[key]); + + const set = (key: string, value: string) => { + setMessage(null); + setForm({ ...form, [key]: value }); + }; + + const validateAndBuild = (): PerformanceSettings | null => { + const errors: Record = {}; + const result = {} as Record; + for (const field of NUMBER_FIELDS) { + const raw = (form[field.key] ?? '').trim(); + if (raw === '') { + result[field.key] = null; + continue; + } + const value = Number(raw); + if (!Number.isInteger(value) || value < field.min || value > field.max) { + errors[field.key] = `Must be a whole number between ${field.min} and ${field.max}.`; + continue; + } + result[field.key] = value; + } + for (const field of BOOLEAN_FIELDS) { + const raw = form[field.key] ?? ''; + result[field.key] = raw === '' ? null : raw === 'true'; + } + setFieldErrors(errors); + return Object.keys(errors).length > 0 ? null : (result as unknown as PerformanceSettings); + }; + + const submit = () => { + const settings = validateAndBuild(); + if (!settings) return; + save.mutate(settings, { + onSuccess: (result) => { + setForm(null); // re-derive from the fresh server response on next load + setMessage( + result.changedFields.length > 0 + ? `Saved ${result.changedFields.length} change${result.changedFields.length === 1 ? '' : 's'} to config.json — restart the server to apply.` + : 'No changes to save.', + ); + }, + onError: (saveError) => setMessage(saveError.message), + }); + }; + + const inputClass = (key: string) => `input w-32 ${fieldErrors[key] ? 'input-error' : ''}`; + + return ( + + unsaved changes + + + + ) + } + > +
+ {NUMBER_FIELDS.map((field) => ( +
+
+

{field.label}

+

+ {field.min}–{field.max} · {field.hint} · blank = game default +

+ {fieldErrors[field.key] && ( +

{fieldErrors[field.key]}

+ )} +
+ set(field.key, event.target.value)} + className={inputClass(field.key)} + /> +
+ ))} + {BOOLEAN_FIELDS.map((field) => ( +
+
+

{field.label}

+

{field.hint}

+
+ +
+ ))} +
+ {message &&

{message}

} +

+ Values are validated against the ranges in the Bohemia server-config reference and written + directly to config.json (backup kept as config.json.bak). Network/identity settings (bind + address, ports, passwords) are never touched here. Changes apply on the next restart. +

+
+ ); +} diff --git a/apps/web/src/components/schedules-card.tsx b/apps/web/src/components/schedules-card.tsx new file mode 100644 index 0000000..da3dc06 --- /dev/null +++ b/apps/web/src/components/schedules-card.tsx @@ -0,0 +1,255 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { RestartScheduleInput, ServerScheduleSummary } from '@reforger-panel/shared'; +import { + useCreateRestartSchedule, + useDeleteSchedule, + useServerSchedules, + useUpdateRestartSchedule, +} from '../api/hooks.js'; +import { formatDateTime } from '../lib/format.js'; +import { Button, Card, EmptyState, Spinner } from './ui.js'; + +const DAYS = [ + { value: '*', label: 'Every day' }, + { value: '0', label: 'Sunday' }, + { value: '1', label: 'Monday' }, + { value: '2', label: 'Tuesday' }, + { value: '3', label: 'Wednesday' }, + { value: '4', label: 'Thursday' }, + { value: '5', label: 'Friday' }, + { value: '6', label: 'Saturday' }, +] as const; + +function pad(n: number): string { + return String(n).padStart(2, '0'); +} + +function timeValue(schedule: ServerScheduleSummary): string { + const hour = Number(schedule.hour); + const minute = Number(schedule.minute); + if (!Number.isInteger(hour) || !Number.isInteger(minute)) return '09:00'; + return `${pad(hour)}:${pad(minute)}`; +} + +function isRestartSchedule(schedule: ServerScheduleSummary): boolean { + return schedule.tasks.some((task) => task.action === 'power' && task.payload === 'restart'); +} + +function describeSchedule(schedule: ServerScheduleSummary): string { + const day = DAYS.find((d) => d.value === schedule.dayOfWeek)?.label ?? schedule.dayOfWeek; + return `${day} at ${timeValue(schedule)}`; +} + +function inputFromSchedule(schedule: ServerScheduleSummary): RestartScheduleInput { + const [hour, minute] = timeValue(schedule).split(':').map(Number); + return { + name: schedule.name, + isActive: schedule.isActive, + minute: minute ?? 0, + hour: hour ?? 9, + dayOfWeek: DAYS.some((d) => d.value === schedule.dayOfWeek) + ? (schedule.dayOfWeek as RestartScheduleInput['dayOfWeek']) + : '*', + onlyWhenOnline: schedule.onlyWhenOnline, + }; +} + +const DEFAULT_INPUT: RestartScheduleInput = { + name: 'Daily restart', + isActive: true, + minute: 0, + hour: 9, + dayOfWeek: '*', + onlyWhenOnline: true, +}; + +export function SchedulesCard({ slug, canEdit }: { slug: string; canEdit: boolean }) { + const { data, isLoading, error } = useServerSchedules(slug, canEdit); + const createSchedule = useCreateRestartSchedule(slug); + const updateSchedule = useUpdateRestartSchedule(slug); + const deleteSchedule = useDeleteSchedule(slug); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(DEFAULT_INPUT); + const [message, setMessage] = useState(null); + + const schedules = data?.schedules ?? []; + const restartSchedules = useMemo(() => schedules.filter(isRestartSchedule), [schedules]); + const editing = restartSchedules.find((schedule) => schedule.id === editingId) ?? null; + + useEffect(() => { + if (editing) setForm(inputFromSchedule(editing)); + }, [editing]); + + if (!canEdit) return null; + + const submit = () => { + setMessage(null); + const options = { + onSuccess: () => { + setMessage(editingId ? 'Restart schedule updated.' : 'Restart schedule created.'); + setEditingId(null); + setForm(DEFAULT_INPUT); + }, + onError: (err: Error) => setMessage(err.message), + }; + if (editingId) { + updateSchedule.mutate({ id: editingId, input: form }, options); + return; + } + createSchedule.mutate(form, options); + }; + + const busy = createSchedule.isPending || updateSchedule.isPending || deleteSchedule.isPending; + + return ( + + {isLoading ? ( + + ) : error ? ( +

{error.message}

+ ) : ( +
+
+ {restartSchedules.length === 0 ? ( + + ) : ( +
    + {restartSchedules.map((schedule) => ( +
  • +
    +

    {schedule.name}

    +

    + {describeSchedule(schedule)} ·{' '} + {schedule.onlyWhenOnline ? 'only when online' : 'runs regardless'} ·{' '} + {schedule.isActive ? 'active' : 'paused'} +

    +

    + next run {schedule.nextRunAt ? formatDateTime(schedule.nextRunAt) : '—'} +

    +
    +
    + + +
    +
  • + ))} +
+ )} +
+ +
+

+ {editingId ? 'Edit restart' : 'New restart'} +

+
+ +
+ + +
+ + +
+ + {editingId && ( + + )} +
+ {message &&

{message}

} +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/startup-vars-card.tsx b/apps/web/src/components/startup-vars-card.tsx new file mode 100644 index 0000000..e3927a8 --- /dev/null +++ b/apps/web/src/components/startup-vars-card.tsx @@ -0,0 +1,117 @@ +import { useState } from 'react'; +import { useStartupVariables, useUpdateStartupVariable } from '../api/hooks.js'; +import { Button, Card, EmptyState, Spinner } from './ui.js'; + +/** + * Pterodactyl egg startup variables (passwords, launch options, …). Values + * are only visible to owner/server admin; changes apply on the next restart. + */ +// Controlled elsewhere in the panel (mission dropdown) or intentionally not +// exposed; hidden here to avoid duplicate/confusing inputs. +const HIDDEN_VARIABLES = new Set(['SCENARIO_ID', 'PUBLIC_ADDRESS']); + +export function StartupVarsCard({ slug }: { slug: string }) { + const { data, isLoading, error } = useStartupVariables(slug, true); + const update = useUpdateStartupVariable(slug); + const [edits, setEdits] = useState>({}); + const [message, setMessage] = useState(null); + const [revealed, setRevealed] = useState>({}); + + const isSecret = (name: string) => /password|token|secret|key/i.test(name); + + const saveVariable = (envVariable: string) => { + const value = edits[envVariable]; + if (value === undefined) return; + setMessage(null); + update.mutate( + { key: envVariable, value }, + { + onSuccess: () => { + setEdits((prev) => { + const next = { ...prev }; + delete next[envVariable]; + return next; + }); + setMessage(`${envVariable} saved — applies on the next restart.`); + }, + onError: (updateError) => setMessage(updateError.message), + }, + ); + }; + + return ( + + {isLoading ? ( + + ) : error ? ( +

{error.message}

+ ) : !data || data.variables.length === 0 ? ( + + ) : ( +
    + {data.variables + .filter((variable) => !HIDDEN_VARIABLES.has(variable.envVariable)) + .map((variable) => { + const edited = edits[variable.envVariable]; + const secret = isSecret(variable.envVariable) || isSecret(variable.name); + const shown = revealed[variable.envVariable] ?? false; + return ( +
  • +
    +

    + {variable.name}{' '} + {variable.envVariable} +

    + {variable.description && ( +

    {variable.description}

    + )} +
    +
    + + setEdits({ ...edits, [variable.envVariable]: event.target.value }) + } + /> + {secret && ( + + )} + {variable.isEditable ? ( + edited !== undefined && + edited !== variable.value && ( + + ) + ) : ( + read-only + )} +
    +
  • + ); + })} +
+ )} + {message &&

{message}

} +

+ These are the same variables as Pterodactyl's Startup tab (server passwords live here, not + in config.json). Changes apply on the next server restart. +

+
+ ); +} diff --git a/apps/web/src/components/ui.tsx b/apps/web/src/components/ui.tsx new file mode 100644 index 0000000..401260a --- /dev/null +++ b/apps/web/src/components/ui.tsx @@ -0,0 +1,162 @@ +import { useState, type ReactNode } from 'react'; +import type { Role, ServerStatus } from '@reforger-panel/shared'; +import { ROLE_LABELS } from '@reforger-panel/shared'; + +export function Card({ + title, + action, + children, + className = '', + padded = true, +}: { + title?: string; + action?: ReactNode; + children: ReactNode; + className?: string; + padded?: boolean; +}) { + return ( +
+ {title !== undefined && ( +
+

{title}

+ {action} +
+ )} +
{children}
+
+ ); +} + +/** Image with a quiet placeholder when the URL is missing or fails to load. */ +export function ModImage({ src, className = '' }: { src: string | null; className?: string }) { + const [failed, setFailed] = useState(false); + if (!src || failed) { + return ( + + + + + + + + ); + } + return ( + setFailed(true)} + className={`shrink-0 rounded-md border border-graphite-700 object-cover ${className}`} + /> + ); +} + +const STATUS_STYLES: Record = { + online: { dot: 'bg-accent-400', text: 'text-accent-400', label: 'Online' }, + offline: { dot: 'bg-zinc-500', text: 'text-zinc-400', label: 'Offline' }, + starting: { dot: 'bg-warn-400 animate-pulse', text: 'text-warn-400', label: 'Starting' }, + stopping: { dot: 'bg-warn-400 animate-pulse', text: 'text-warn-400', label: 'Stopping' }, + unknown: { dot: 'bg-zinc-600', text: 'text-zinc-500', label: 'Unknown' }, +}; + +export function StatusBadge({ status }: { status: ServerStatus }) { + const style = STATUS_STYLES[status] ?? STATUS_STYLES.unknown; + return ( + + + {style.label} + + ); +} + +const ROLE_STYLES: Record = { + owner: 'border-accent-500/40 bg-accent-500/10 text-accent-400', + server_admin: 'border-sky-500/40 bg-sky-500/10 text-sky-400', + mission_lead: 'border-warn-400/40 bg-warn-400/10 text-warn-400', + viewer: 'border-zinc-600 bg-zinc-800/60 text-zinc-400', +}; + +export function RoleBadge({ role }: { role: Role }) { + return ( + + {ROLE_LABELS[role]} + + ); +} + +export function EmptyState({ title, hint }: { title: string; hint?: string }) { + return ( +
+

{title}

+ {hint &&

{hint}

} +
+ ); +} + +export function Spinner({ label = 'Loading…' }: { label?: string }) { + return ( +
+ + {label} +
+ ); +} + +export function StatBar({ + value, + max, + warnAt = 0.8, +}: { + value: number; + max: number | null; + warnAt?: number; +}) { + if (!max || max <= 0) return null; + const ratio = Math.min(1, value / max); + const color = ratio >= warnAt ? 'bg-warn-400' : 'bg-accent-500'; + return ( +
+
+
+ ); +} + +export function Button({ + children, + onClick, + disabled, + variant = 'default', + title, +}: { + children: ReactNode; + onClick?: () => void; + disabled?: boolean; + variant?: 'default' | 'accent' | 'danger'; + title?: string; +}) { + const variants = { + default: + 'border-graphite-600 bg-graphite-800 text-zinc-300 hover:border-graphite-600 hover:bg-graphite-700', + accent: 'border-accent-600/60 bg-accent-600/15 text-accent-400 hover:bg-accent-600/25', + danger: 'border-danger-400/40 bg-danger-400/10 text-danger-400 hover:bg-danger-400/20', + } as const; + return ( + + ); +} diff --git a/apps/web/src/components/widgets.tsx b/apps/web/src/components/widgets.tsx new file mode 100644 index 0000000..f5fbae0 --- /dev/null +++ b/apps/web/src/components/widgets.tsx @@ -0,0 +1,343 @@ +import { useState } from 'react'; +import type { + ActivityItem, + Capability, + ConfigurationResponse, + CurrentUser, + PlayersResponse, + ServerSummary, +} from '@reforger-panel/shared'; +import { + useActivity, + useLogHealth, + useManualLogSync, + usePlayers, + usePowerAction, + useWorkshopHealth, +} from '../api/hooks.js'; +import { formatDateTime, formatDuration, formatRelativeTime } from '../lib/format.js'; +import { Button, Card, EmptyState, Spinner } from './ui.js'; + +function can(user: CurrentUser, capability: Capability): boolean { + return user.capabilities.includes(capability); +} + +export function PowerControls({ user, server }: { user: CurrentUser; server: ServerSummary }) { + const power = usePowerAction(server.slug); + const [message, setMessage] = useState(null); + + const run = (action: 'start' | 'stop' | 'restart') => { + setMessage(null); + power.mutate(action, { + onSuccess: (result) => + setMessage(result.simulated ? `${action} simulated (mock mode)` : `${action} requested`), + onError: (error) => setMessage(error.message), + }); + }; + + const canStart = can(user, 'server.power.start'); + const canStop = can(user, 'server.power.stop'); + const canRestart = can(user, 'server.power.restart'); + if (!canStart && !canStop && !canRestart) return null; + + return ( +
+ {canStart && ( + + )} + {canRestart && ( + + )} + {canStop && ( + + )} + {message && {message}} +
+ ); +} + +export function CurrentPlayersCard({ + slug, + maxPlayers, +}: { + slug: string; + maxPlayers: number | null; +}) { + const { data, isLoading } = usePlayers(slug); + return ( + + {data.stale ? ( + data may be stale + ) : ( + <>last synchronized {formatRelativeTime(data.lastSyncedAt)} + )} + + ) + } + > + {isLoading || !data ? ( + + ) : ( + + )} + + ); +} + +function PlayersTable({ + players, + maxPlayers, +}: { + players: PlayersResponse; + maxPlayers: number | null; +}) { + return ( +
+

+ {players.onlineCount} + / {maxPlayers ?? '—'} online +

+ {players.players.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + {players.players.map((player) => ( + + + + + + ))} + +
PlayerConnected sinceSession
{player.displayName}{formatDateTime(player.connectedAt)} + {formatDuration(player.sessionDurationSeconds)} +
+
+ )} +
+ ); +} + +const ACTIVITY_COLORS: Record = { + player_connected: 'text-accent-400', + player_disconnected: 'text-slate-ink', + server_started: 'text-accent-400', + server_stopped: 'text-warn-400', + server_restart_detected: 'text-warn-400', + log_sync_failed: 'text-danger-400', +}; + +function logTimestamp(iso: string): string { + const date = new Date(iso); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} + +/** Log-style feed: monospace timestamps, fixed height, scrolls. */ +export function ActivityList({ + items, + maxHeight = 320, +}: { + items: ActivityItem[]; + maxHeight?: number; +}) { + if (items.length === 0) { + return ( + + ); + } + return ( +
+
    + {items.map((item) => ( +
  • + {logTimestamp(item.occurredAt)} + + {item.summary} + + + {item.kind === 'panel_action' ? 'panel' : 'server'} + +
  • + ))} +
+
+ ); +} + +export function RecentActivityCard({ slug, limit = 50 }: { slug: string; limit?: number }) { + const { data, isLoading } = useActivity(slug, limit); + return ( + + {isLoading || !data ? : } + + ); +} + +/** Display form of a scenario id: just the file name, e.g. "23_Campaign.conf". */ +export function shortScenario(scenarioId: string): string { + const slash = scenarioId.lastIndexOf('/'); + return slash >= 0 ? scenarioId.slice(slash + 1) : scenarioId; +} + +export function ConfigSummaryRows({ config }: { config: ConfigurationResponse }) { + const c = config.config; + const rows: [string, string][] = [ + ['Mission', shortScenario(c.scenarioId)], + ['Max players', String(c.maxPlayers)], + // Reforger uses -1 for "no AI limit". + ['AI limit', c.aiLimit < 0 ? 'Unlimited' : String(c.aiLimit)], + ['View distance', `${c.serverMaxViewDistance} m (network ${c.networkViewDistance} m)`], + ['Third person', c.disableThirdPerson ? 'Disabled' : 'Allowed'], + ['Cross-platform', c.crossPlatform ? 'Enabled' : 'Disabled'], + ['Mods', `${c.mods.length}`], + ]; + return ( +
+ {rows.map(([label, value]) => ( +
+
{label}
+
+ {value} +
+
+ ))} +
+ ); +} + +export function OpsHealthCard({ user, slug }: { user: CurrentUser; slug: string }) { + const visible = can(user, 'ops.health.view'); + const { data: workshop } = useWorkshopHealth(); + const { data: logs } = useLogHealth(slug, visible); + const syncNow = useManualLogSync(slug); + const [syncMessage, setSyncMessage] = useState(null); + if (!visible) return null; + + return ( + + syncNow.mutate(undefined, { + onSuccess: (result) => + setSyncMessage( + `Synced: ${result.processedLines} lines, ${result.createdEvents} new events`, + ), + onError: (error) => setSyncMessage(error.message), + }) + } + > + {syncNow.isPending ? 'Syncing…' : 'Sync logs now'} + + ) + } + > +
+
+
Workshop API
+
+ {workshop ? ( + workshop.ok ? ( + + healthy · {workshop.latencyMs} ms · {formatRelativeTime(workshop.checkedAt)} + + ) : ( + + unreachable + + ) + ) : ( + checking… + )} +
+
+
+
Log ingestion
+
+ {!logs ? ( + checking… + ) : !logs.configured ? ( + not configured + ) : logs.stale ? ( + stale + ) : ( + healthy + )} +
+
+
+
Last successful sync
+
+ {formatRelativeTime(logs?.lastSuccessfulSyncAt ?? null)} +
+
+ {logs?.lastSync && ( +
+
Last sync processed
+
+ {logs.lastSync.processedLines} lines · {logs.lastSync.createdEvents} events +
+
+ )} + {logs?.lastErrorMessage && ( +
+
Last sync error
+
+ {logs.lastErrorMessage} +
+
+ )} + {syncMessage &&

{syncMessage}

} +
+
+ ); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css new file mode 100644 index 0000000..03ef732 --- /dev/null +++ b/apps/web/src/index.css @@ -0,0 +1,124 @@ +@import 'tailwindcss'; + +@theme { + --color-graphite-950: #12161b; + --color-graphite-900: #191e24; + --color-graphite-850: #20262e; + --color-graphite-800: #29313a; + --color-graphite-700: #3a4552; + --color-graphite-600: #505c69; + --color-slate-ink: #b1bac4; + --color-slate-dim: #838e9a; + --color-accent-500: #6f8fab; + --color-accent-400: #9bb4ca; + --color-accent-600: #58758e; + --color-warn-400: #d2a85b; + --color-danger-400: #d37a70; + + --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace; +} + +body { + @apply bg-graphite-950 text-zinc-200 antialiased; + background: var(--color-graphite-950); +} + +button, +a, +input, +select, +textarea { + @apply outline-none; +} + +:focus-visible { + @apply ring-2 ring-accent-500/45 ring-offset-2 ring-offset-graphite-950; +} + +::selection { + background: color-mix(in srgb, var(--color-accent-500) 35%, transparent); +} + +.panel-card { + /* min-w-0 lets cards shrink inside grid tracks instead of widening them. */ + @apply min-w-0 rounded-lg border border-graphite-700/70 bg-graphite-900 shadow-sm shadow-black/20; +} + +.panel-card-header { + @apply flex flex-wrap items-center justify-between gap-3 border-b border-graphite-700/60 px-5 py-4; +} + +.panel-card-title { + @apply text-xs font-semibold uppercase tracking-[0.14em] text-slate-ink; +} + +.page-title { + @apply text-2xl font-semibold text-zinc-100; + letter-spacing: 0; +} + +.page-kicker { + @apply mt-1 max-w-2xl text-sm leading-6 text-slate-ink; +} + +.input { + @apply rounded-md border border-graphite-600 bg-graphite-950/55 px-3 py-2 text-sm text-zinc-200 shadow-sm transition-colors placeholder:text-slate-dim hover:border-slate-dim/70 focus:border-accent-500 disabled:cursor-not-allowed disabled:opacity-50; +} + +.input-error { + @apply border-danger-400/70 focus:border-danger-400 focus:ring-danger-400/30; +} + +/* No native number spinners — they clash with the theme. */ +input[type='number'].input { + appearance: textfield; + -moz-appearance: textfield; +} +input[type='number'].input::-webkit-inner-spin-button, +input[type='number'].input::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Selects: replace the native chrome with a themed chevron. */ +select.input { + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b98a5' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.65rem center; + padding-right: 2rem; +} +select.input option { + @apply bg-graphite-850 text-zinc-200; +} + +.data-table-scroll { + @apply overflow-x-auto; +} + +.data-table { + @apply w-full min-w-fit text-sm; +} + +.data-table th, +.data-table td { + @apply whitespace-nowrap pr-4 last:pr-0; +} + +.data-table thead tr { + @apply border-b border-graphite-700/60 text-left text-[11px] uppercase tracking-wider text-slate-dim; +} + +.data-table th { + @apply pb-2 font-medium; +} + +.data-table tbody tr { + @apply border-b border-graphite-800/80 last:border-0 hover:bg-graphite-850/50; +} + +.data-table td { + @apply py-2.5; +} diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts new file mode 100644 index 0000000..97e25e2 --- /dev/null +++ b/apps/web/src/lib/format.ts @@ -0,0 +1,47 @@ +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; + const exponent = Math.min(Math.floor(Math.log2(bytes) / 10), units.length - 1); + const value = bytes / 2 ** (10 * exponent); + return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[exponent]}`; +} + +export function formatDuration(totalSeconds: number): string { + if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '—'; + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + if (hours >= 24) { + const days = Math.floor(hours / 24); + return `${days}d ${hours % 24}h`; + } + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m`; + return `${Math.floor(totalSeconds)}s`; +} + +export function formatRelativeTime(iso: string | null): string { + if (!iso) return '—'; + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return '—'; + const seconds = Math.round((Date.now() - then) / 1000); + if (seconds < 5) return 'just now'; + if (seconds < 60) return `${seconds} seconds ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`; + const days = Math.floor(hours / 24); + return `${days} day${days === 1 ? '' : 's'} ago`; +} + +export function formatDateTime(iso: string | null): string { + if (!iso) return '—'; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return '—'; + return date.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..cc7ecff --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App.js'; +import './index.css'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/apps/web/src/pages/login.tsx b/apps/web/src/pages/login.tsx new file mode 100644 index 0000000..5c5bdc4 --- /dev/null +++ b/apps/web/src/pages/login.tsx @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '../api/client.js'; + +function DiscordMark({ className }: { className?: string }) { + return ( + + + + ); +} + +export function LoginPage() { + const [devError, setDevError] = useState(null); + const { data: options } = useQuery({ + queryKey: ['auth', 'options'], + queryFn: () => api.get<{ discord: boolean; devLogin: boolean }>('/api/auth/options'), + staleTime: Infinity, + }); + + // Invite links land here before login; stash the code so it can be redeemed + // automatically right after the Discord round-trip. + const inviteCode = new URLSearchParams(window.location.search).get('invite'); + if (inviteCode) { + localStorage.setItem('rp_invite', inviteCode); + } + const pendingInvite = inviteCode ?? localStorage.getItem('rp_invite'); + + const devLogin = async () => { + try { + await api.post('/api/auth/dev-login'); + window.location.reload(); + } catch { + setDevError('Dev login is not enabled (set DEV_AUTH_BYPASS=true locally).'); + } + }; + + return ( +
+
+
+
+
+ + DZR + +

+ DZR.TOOLS +

+

+ Arma Reforger Ops +

+
+ {pendingInvite && ( +

+ Invite detected. Sign in with Discord and the role will be applied automatically. +

+ )} + + + Continue with Discord + + {options?.devLogin && ( + + )} + {devError &&

{devError}

} +
+
+ ); +} diff --git a/apps/web/src/pages/logs.tsx b/apps/web/src/pages/logs.tsx new file mode 100644 index 0000000..7e61685 --- /dev/null +++ b/apps/web/src/pages/logs.tsx @@ -0,0 +1,90 @@ +import { useEffect, useRef, useState } from 'react'; +import { useRawLogs, useServers } from '../api/hooks.js'; +import { formatRelativeTime } from '../lib/format.js'; +import { Button, Card, Spinner } from '../components/ui.js'; + +export function LogsPage() { + const { data: serversData } = useServers(); + const slug = serversData?.servers[0]?.slug; + const [lines, setLines] = useState(300); + const [autoRefresh, setAutoRefresh] = useState(true); + const [follow, setFollow] = useState(true); + const { data, isLoading, error, refetch, isFetching } = useRawLogs( + slug ?? '', + lines, + autoRefresh, + slug !== undefined, + ); + const viewportRef = useRef(null); + + useEffect(() => { + if (follow && viewportRef.current) { + viewportRef.current.scrollTop = viewportRef.current.scrollHeight; + } + }, [data, follow]); + + if (!slug) return ; + + return ( +
+

Logs

+ + {data && ( + + fetched {formatRelativeTime(data.fetchedAt)} + + )} + + + + +
+ } + > + {isLoading ? ( + + ) : error ? ( +

{error.message}

+ ) : ( +
+            {data?.lines.join('\n')}
+          
+ )} +

+ Read-only tail of the current Reforger console log, downloaded through the Pterodactyl + API. Visible to owner and server admins only. +

+ +
+ ); +} diff --git a/apps/web/src/pages/mods.tsx b/apps/web/src/pages/mods.tsx new file mode 100644 index 0000000..692cfc0 --- /dev/null +++ b/apps/web/src/pages/mods.tsx @@ -0,0 +1,435 @@ +import { useState } from 'react'; +import type { CurrentUser, ReforgerConfigMod, WorkshopModDetail } from '@reforger-panel/shared'; +import { api } from '../api/client.js'; +import { + useServerMods, + useServers, + useSetServerMods, + useWorkshopMod, + useWorkshopSearch, +} from '../api/hooks.js'; +import { formatRelativeTime } from '../lib/format.js'; +import { Button, Card, EmptyState, ModImage, Spinner } from '../components/ui.js'; + +const COMMON_WORKSHOP_TAGS = [ + 'WEAPONS', + 'VEHICLES', + 'MISSIONS', + 'EQUIPMENT', + 'GAMEPLAY', + 'MISC', + 'QUALITY OF LIFE', +] as const; + +const WORKSHOP_SORTS = [ + { value: 'popularity', label: 'Popular' }, + { value: 'newest', label: 'Newest' }, + { value: 'subscribers', label: 'Subscribers' }, + { value: 'version_size', label: 'Size' }, +] as const; + +export function ModsPage({ user }: { user: CurrentUser }) { + const { data: serversData } = useServers(); + const slug = serversData?.servers[0]?.slug; + if (!slug) return ; + return ; +} + +function sameMods(a: ReforgerConfigMod[], b: ReforgerConfigMod[]): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) { + const canManage = user.capabilities.includes('mods.manage'); + const { data, isLoading, error, refetch } = useServerMods(slug); + const save = useSetServerMods(slug); + const [draft, setDraft] = useState(null); + const [message, setMessage] = useState(null); + + const serverMods = data?.mods ?? []; + const mods = draft ?? serverMods; + const dirty = draft !== null && !sameMods(draft, serverMods); + const installedIds = new Set(mods.map((mod) => mod.modId.toUpperCase())); + + const addMod = (mod: ReforgerConfigMod) => { + if (installedIds.has(mod.modId.toUpperCase())) return; + setMessage(null); + setDraft([...mods, mod]); + }; + + const removeMod = (modId: string) => { + setMessage(null); + setDraft(mods.filter((mod) => mod.modId !== modId)); + }; + + const saveMods = () => { + setMessage(null); + save.mutate(mods, { + onSuccess: (result) => { + setDraft(null); + setMessage( + `Saved to config.json — ${result.added} added, ${result.removed} removed. ` + + 'Restart the server to apply.', + ); + void refetch(); + }, + onError: (saveError) => setMessage(saveError.message), + }); + }; + + return ( +
+
+

Mods

+

+ Review the live server mod list, stage changes, and pull metadata from the Reforger + Workshop before saving config.json. +

+
+ + + {data && !dirty && ( + + fetched {formatRelativeTime(data.fetchedAt)} + + )} + {dirty && ( + <> + unsaved changes + + + + )} +
+ } + > + {isLoading ? ( + + ) : error ? ( +

{error.message}

+ ) : mods.length === 0 ? ( + + ) : ( +
    + {mods.map((mod) => ( +
  • +
    +

    + {mod.name ?? mod.modId} +

    +

    + {mod.modId} + {mod.version ? ` · v${mod.version}` : ' · latest version'} +

    +
    + {canManage && ( + + )} +
  • + ))} +
+ )} + {message &&

{message}

} +

+ Changes are written directly to the server's config.json (a config.json.bak backup is + kept) and take effect on the next server restart. +

+ + + +
+ ); +} + +function WorkshopBrowser({ + canManage, + installedIds, + onAdd, +}: { + canManage: boolean; + installedIds: Set; + onAdd: (mod: ReforgerConfigMod) => void; +}) { + const [input, setInput] = useState(''); + const [query, setQuery] = useState(''); + const [activeTag, setActiveTag] = useState(null); + const [sort, setSort] = useState<(typeof WORKSHOP_SORTS)[number]['value']>('popularity'); + const [page, setPage] = useState(1); + const [selectedModId, setSelectedModId] = useState(null); + const [addingId, setAddingId] = useState(null); + const effectiveQuery = [query, activeTag].filter(Boolean).join(' '); + const { data, isFetching, error } = useWorkshopSearch(effectiveQuery, page, sort); + + // Adding needs the mod's version, which only the detail endpoint provides. + const addFromWorkshop = async (modId: string, fallbackName: string) => { + setAddingId(modId); + try { + const detail = await api.get(`/api/workshop/mods/${modId}`); + onAdd({ + modId: detail.id, + name: detail.name || fallbackName, + ...(detail.version ? { version: detail.version } : {}), + }); + } catch { + onAdd({ modId, name: fallbackName }); + } finally { + setAddingId(null); + } + }; + + return ( + +
{ + event.preventDefault(); + setPage(1); + setSelectedModId(null); + setQuery(input.trim()); + }} + > + setInput(event.target.value)} + placeholder="Search the Reforger Workshop… (empty shows the front page)" + className="input min-w-0 flex-1" + /> + + +
+ +
+ Tags + {COMMON_WORKSHOP_TAGS.map((tag) => ( + + ))} + {activeTag && ( + + )} +
+ + {error &&

{error.message}

} + {!data && !error && } + {data && ( +
+
+

+ {effectiveQuery + ? `${data.meta.totalMods.toLocaleString()} results for “${effectiveQuery}”` + : `${data.meta.totalMods.toLocaleString()} Workshop mods`}{' '} + · page {data.meta.currentPage} of {data.meta.totalPages} +

+
    + {data.mods.map((mod) => { + const installed = installedIds.has(mod.id.toUpperCase()); + return ( +
  • + + {canManage && ( + + )} +
  • + ); + })} +
+
+ + +
+
+ { + setActiveTag(tag); + setPage(1); + setSelectedModId(null); + }} + /> +
+ )} +
+ ); +} + +function ModDetailPanel({ + modId, + canManage, + installedIds, + onAdd, + onTagSelect, +}: { + modId: string | null; + canManage: boolean; + installedIds: Set; + onAdd: (mod: ReforgerConfigMod) => void; + onTagSelect: (tag: string) => void; +}) { + const { data: mod, isLoading } = useWorkshopMod(modId); + if (!modId) { + return ( +
+ +
+ ); + } + if (isLoading || !mod) return ; + const installed = installedIds.has(mod.id.toUpperCase()); + return ( +
+
+ +
+

{mod.name}

+

+ by {mod.author} · v{mod.version ?? '—'} · game {mod.gameVersion ?? '—'} +

+

+ {mod.downloads?.toLocaleString() ?? '—'} downloads · {mod.rating ?? '—'} rating ·{' '} + {mod.size ?? '—'} +

+
+
+ {mod.summary &&

{mod.summary}

} + {mod.tags.length > 0 && ( +
+ {mod.tags.map((tag) => ( + + ))} +
+ )} + {mod.dependencies.length > 0 && ( +
+

+ Dependencies (add these too) +

+
    + {mod.dependencies.map((dep) => ( +
  • {dep.name}
  • + ))} +
+
+ )} +
+ {canManage && ( + + )} + {mod.workshopUrl && ( + + Open in Workshop ↗ + + )} +
+
+ ); +} diff --git a/apps/web/src/pages/overview.tsx b/apps/web/src/pages/overview.tsx new file mode 100644 index 0000000..816da15 --- /dev/null +++ b/apps/web/src/pages/overview.tsx @@ -0,0 +1,181 @@ +import { Link } from 'react-router-dom'; +import type { CurrentUser, ResourceSample } from '@reforger-panel/shared'; +import { + useConfiguration, + useResourceHistory, + useServerResources, + useServers, +} from '../api/hooks.js'; +import { formatBytes, formatDuration } from '../lib/format.js'; +import { Card, Spinner } from '../components/ui.js'; +import { TimeSeriesChart } from '../components/charts.js'; +import { + ConfigSummaryRows, + CurrentPlayersCard, + OpsHealthCard, + RecentActivityCard, +} from '../components/widgets.js'; + +export function OverviewPage({ user }: { user: CurrentUser }) { + const { data: serversData, isLoading } = useServers(); + const server = serversData?.servers[0]; + + if (isLoading) return ; + if (!server) { + return ( + +

+ No servers found. Run npm run db:seed{' '} + to create the training server. +

+
+ ); + } + return ; +} + +function seriesOf( + samples: ResourceSample[] | undefined, + pick: (s: ResourceSample) => number, +): { t: number; v: number }[] { + return (samples ?? []).map((s) => ({ t: s.t, v: pick(s) })); +} + +function Dashboard({ user, slug }: { user: CurrentUser; slug: string }) { + const { data: serversData } = useServers(); + const server = serversData?.servers.find((s) => s.slug === slug); + const { data: resources } = useServerResources(slug); + const { data: config } = useConfiguration(slug); + const { data: history } = useResourceHistory(slug); + if (!server) return null; + + const installedMods = config?.config.mods ?? []; + const samples = history?.samples; + const memoryLimit = resources?.memoryLimitBytes ?? samples?.at(-1)?.memoryLimitBytes ?? null; + const cpuLimit = resources?.cpuLimitPercent ?? samples?.at(-1)?.cpuLimitPercent ?? 100; + + return ( +
+
+ +

+ {resources ? `${resources.cpuPercent.toFixed(0)}%` : '—'} + + {cpuLimit && cpuLimit !== 100 ? ` / ${cpuLimit}%` : ''} + +

+ s.cpuPercent), + color: 'var(--color-accent-400)', + }, + ]} + /> +
+ + +

+ {resources ? formatBytes(resources.memoryBytes) : '—'} + + {memoryLimit ? ` / ${formatBytes(memoryLimit)}` : ''} + +

+ s.memoryBytes), + color: '#7dd3fc', + }, + ]} + /> +
+ + +

+ + ↓ {formatBytes(samples?.at(-1)?.networkRxRate ?? 0)}/s + + + ↑ {formatBytes(samples?.at(-1)?.networkTxRate ?? 0)}/s + + + up{' '} + {resources && resources.uptimeMs > 0 + ? formatDuration(resources.uptimeMs / 1000) + : '—'} + +

+ s.networkRxRate), + color: 'var(--color-accent-400)', + label: 'rx', + }, + { + points: seriesOf(samples, (s) => s.networkTxRate), + color: 'var(--color-warn-400)', + fill: false, + label: 'tx', + }, + ]} + /> +
+
+ +
+
+ + +
+
+ + View configuration + + } + > + {config ? : } + + + Manage + + } + > + {installedMods.length === 0 ? ( +

The server runs vanilla (no mods).

+ ) : ( +
+

+ {installedMods.length} mod{installedMods.length === 1 ? '' : 's'} in config.json +

+
    + {installedMods.slice(0, 5).map((mod) => ( +
  • + {mod.name ?? mod.modId} +
  • + ))} + {installedMods.length > 5 && ( +
  • + {installedMods.length - 5} more
  • + )} +
+
+ )} +
+ +
+
+
+ ); +} diff --git a/apps/web/src/pages/simple-pages.tsx b/apps/web/src/pages/simple-pages.tsx new file mode 100644 index 0000000..518e881 --- /dev/null +++ b/apps/web/src/pages/simple-pages.tsx @@ -0,0 +1,372 @@ +import { useMemo, useState } from 'react'; +import type { CurrentUser, Role } from '@reforger-panel/shared'; +import { ROLES, ROLE_LABELS } from '@reforger-panel/shared'; +import { + useActivity, + useConfiguration, + useKnownPlayers, + useKillfeed, + useLogHealth, + usePlayers, + useServers, + useSetUserRole, + useUsers, + useWorkshopHealth, +} from '../api/hooks.js'; +import { formatDateTime, formatDuration, formatRelativeTime } from '../lib/format.js'; +import { Card, EmptyState, RoleBadge, Spinner } from '../components/ui.js'; +import { ActivityList, ConfigSummaryRows, CurrentPlayersCard } from '../components/widgets.js'; +import { InvitesCard } from '../components/invites-card.js'; +import { MissionCard } from '../components/mission-card.js'; +import { PerformanceForm } from '../components/performance-form.js'; +import { SchedulesCard } from '../components/schedules-card.js'; +import { StartupVarsCard } from '../components/startup-vars-card.js'; + +function usePrimarySlug(): string | null { + const { data } = useServers(); + return data?.servers[0]?.slug ?? null; +} + +export function ConfigurationsPage({ user }: { user: CurrentUser }) { + const slug = usePrimarySlug(); + if (!slug) return ; + return ; +} + +function ConfigurationsBody({ slug, user }: { slug: string; user: CurrentUser }) { + const { data: config } = useConfiguration(slug); + const canEdit = user.capabilities.includes('config.edit'); + + return ( +
+

Configuration

+ + + + {canEdit && } + + {config ? : } + +
+ ); +} + +export function PlayersPage() { + const slug = usePrimarySlug(); + if (!slug) return ; + return ; +} + +function PlayersBody({ slug }: { slug: string }) { + const { data: online } = usePlayers(slug); + const { data: known } = useKnownPlayers(slug); + const [sort, setSort] = useState<'online' | 'last_seen' | 'playtime' | 'sessions' | 'name'>( + 'online', + ); + const sortedPlayers = useMemo(() => { + const players = [...(known?.players ?? [])]; + players.sort((a, b) => { + if (sort === 'online') { + if (a.online !== b.online) return a.online ? -1 : 1; + return b.lastSeenAt.localeCompare(a.lastSeenAt); + } + if (sort === 'last_seen') return b.lastSeenAt.localeCompare(a.lastSeenAt); + if (sort === 'playtime') return b.totalPlaytimeSeconds - a.totalPlaytimeSeconds; + if (sort === 'sessions') return b.totalSessions - a.totalSessions; + return a.displayName.localeCompare(b.displayName); + }); + return players; + }, [known?.players, sort]); + + return ( +
+

Players

+ + setSort(event.target.value as typeof sort)} + className="input py-1.5 text-xs" + > + + + + + + + } + > + {!known ? ( + + ) : known.players.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {sortedPlayers.map((player) => ( + + + + + + + + ))} + +
PlayerIdentityLast seenSessionsPlaytime
+ {player.displayName} + {player.online && ( + + online + + )} + + {player.externalPlayerId ? ( + player.externalPlayerId.slice(0, 12) + '…' + ) : ( + name only + )} + {formatRelativeTime(player.lastSeenAt)}{player.totalSessions} + {formatDuration(player.totalPlaytimeSeconds)} +
+
+ )} +
+
+ ); +} + +export function ActivityPage() { + const slug = usePrimarySlug(); + if (!slug) return ; + return ; +} + +export function KillfeedPage() { + const slug = usePrimarySlug(); + if (!slug) return ; + return ; +} + +function teamClass(team: string | null): string { + const normalized = team?.toLowerCase() ?? ''; + if (normalized.includes('blue') || normalized.includes('blufor')) return 'bg-sky-500'; + if (normalized.includes('opfor') || normalized.includes('red')) return 'bg-red-500'; + if (normalized.includes('independent') || normalized.includes('green')) return 'bg-emerald-500'; + return 'bg-slate-dim'; +} + +function positionLabel(position: { x: number; y: number; z?: number | null } | null): string { + if (!position) return 'position unknown'; + const z = typeof position.z === 'number' ? `, ${position.z.toFixed(0)}` : ''; + return `${position.x.toFixed(0)}, ${position.y.toFixed(0)}${z}`; +} + +function KillfeedBody({ slug }: { slug: string }) { + const { data, isLoading } = useKillfeed(slug, 150); + return ( +
+
+

Killfeed

+

+ Parsed from ServerAdminTools kill events. Team, position, distance, and weapon show when + the log line provides them. +

+
+ + {isLoading || !data ? ( + + ) : data.events.length === 0 ? ( + + ) : ( +
    + {data.events.map((event) => ( +
  • +
    + + {event.killerName} + killed + + {event.victimName} + {event.friendly && ( + + friendly + + )} +
    +
    + {formatDateTime(event.occurredAt)} + attacker {positionLabel(event.killerPosition)} + victim {positionLabel(event.victimPosition)} + + distance{' '} + {event.distanceMeters !== null ? `${event.distanceMeters.toFixed(0)} m` : '—'} + + weapon {event.weapon ?? '—'} +
    +
  • + ))} +
+ )} +
+
+ ); +} + +function ActivityBody({ slug }: { slug: string }) { + const { data } = useActivity(slug, 100); + return ( +
+

Activity

+ {data ? : } +
+ ); +} + +export function SettingsPage({ user }: { user: CurrentUser }) { + const isOwner = user.role === 'owner'; + const slug = usePrimarySlug(); + const { data: users } = useUsers(isOwner); + const { data: workshop } = useWorkshopHealth(); + const { data: logs } = useLogHealth(slug ?? '', isOwner && slug !== null); + const setRole = useSetUserRole(); + + return ( +
+
+

Settings

+

+ Manage private Discord access, server integrations, and the checks that matter before + exposing the panel to friends. +

+
+ + +
+ {user.avatarUrl ? ( + + ) : ( + + {(user.displayName ?? user.username).slice(0, 1).toUpperCase()} + + )} +
+

+ {user.displayName ?? user.username}{' '} + ({user.username}) +

+ +
+
+
+ + {isOwner && ( + + {!users ? ( + + ) : ( +
    + {users.users.map((panelUser) => ( +
  • +
    + {panelUser.avatarUrl ? ( + + ) : ( + + )} +
    +

    + {panelUser.displayName ?? panelUser.username} +

    +

    + joined {formatDateTime(panelUser.createdAt)} +

    +
    +
    + {panelUser.id === user.id ? ( + + ) : ( + + )} +
  • + ))} +
+ )} +
+ )} + + {isOwner && } + + {isOwner && ( + +
+
+
Workshop API
+
+ {workshop + ? workshop.ok + ? `healthy (${workshop.latencyMs} ms)` + : 'unreachable' + : '—'} +
+
+
+
Pterodactyl
+
+ {logs?.configured ? 'configured' : 'mock / not configured'} +
+
+
+
Log path
+
{logs?.logPath ?? '—'}
+
+
+

+ Connection settings are managed through environment variables. Use real Pterodactyl + client API credentials for production and keep mock mode off. +

+
+ )} +
+ ); +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..4931f36 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "noEmit": true, + "useDefineForClassFields": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..1ef7267 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: false, + }, + }, + }, +}); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..bae6853 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,38 @@ +# Private production deployment: Postgres + the panel in one stack. +# cp .env.example .env (fill in Discord, Pterodactyl, secrets) +# docker compose -f docker-compose.prod.yml up -d --build +# Put a reverse proxy with HTTPS (Caddy/nginx/Tailscale Serve) in front of +# port 3001 and point DISCORD_REDIRECT_URI / WEB_ORIGIN at that public URL. +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: reforger + POSTGRES_PASSWORD: reforger + POSTGRES_DB: reforger_panel + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U reforger -d reforger_panel'] + interval: 5s + timeout: 3s + retries: 10 + + panel: + build: . + restart: unless-stopped + env_file: .env + environment: + NODE_ENV: production + # Inside the compose network Postgres is reachable by service name. + DATABASE_URL: postgresql://reforger:reforger@postgres:5432/reforger_panel + DEV_AUTH_BYPASS: 'false' + ports: + - '3001:3001' + depends_on: + postgres: + condition: service_healthy + +volumes: + postgres-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7f37394 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + postgres: + image: postgres:16-alpine + container_name: reforger-panel-postgres + restart: unless-stopped + environment: + POSTGRES_USER: reforger + POSTGRES_PASSWORD: reforger + POSTGRES_DB: reforger_panel + ports: + # Host port 5433 to avoid clashing with any locally installed Postgres. + - '127.0.0.1:5433:5432' + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U reforger -d reforger_panel'] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + postgres-data: diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..924bee4 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,21 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import prettier from 'eslint-config-prettier'; + +export default tseslint.config( + { + ignores: ['**/dist/**', '**/build/**', '**/node_modules/**', '**/coverage/**', '**/drizzle/**'], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + prettier, + { + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }, + ], + '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], + }, + }, +); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ff929b3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,9855 @@ +{ + "name": "reforger-panel", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "reforger-panel", + "version": "0.1.0", + "workspaces": [ + "packages/*", + "apps/*" + ], + "devDependencies": { + "@eslint/js": "^9.30.0", + "eslint": "^9.30.0", + "eslint-config-prettier": "^10.1.0", + "npm-run-all": "^4.1.5", + "prettier": "^3.6.0", + "typescript": "^5.8.0", + "typescript-eslint": "^8.35.0" + }, + "engines": { + "node": ">=22" + } + }, + "apps/api": { + "name": "@reforger-panel/api", + "version": "0.1.0", + "dependencies": { + "@reforger-panel/shared": "*", + "cookie": "^1.0.2", + "drizzle-orm": "^0.44.0", + "express": "^5.1.0", + "pg": "^8.16.0", + "pino": "^9.7.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/node": "^24.0.0", + "@types/pg": "^8.15.0", + "@types/supertest": "^6.0.0", + "drizzle-kit": "^0.31.0", + "pino-pretty": "^13.0.0", + "supertest": "^7.1.0", + "tsup": "^8.5.0", + "tsx": "^4.20.0", + "vitest": "^3.2.0" + } + }, + "apps/web": { + "name": "@reforger-panel/web", + "version": "0.1.0", + "dependencies": { + "@reforger-panel/shared": "*", + "@tanstack/react-query": "^5.80.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.6.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.5.0", + "tailwindcss": "^4.1.0", + "typescript": "^5.8.0", + "vite": "^6.3.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@reforger-panel/api": { + "resolved": "apps/api", + "link": true + }, + "node_modules/@reforger-panel/shared": { + "resolved": "packages/shared", + "link": true + }, + "node_modules/@reforger-panel/web": { + "resolved": "apps/web", + "link": true + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", + "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", + "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/superagent": { + "version": "8.1.10", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.10.tgz", + "integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", + "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/type-utils": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.62.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", + "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", + "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.62.1", + "@typescript-eslint/types": "^8.62.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", + "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", + "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", + "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", + "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", + "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.62.1", + "@typescript-eslint/tsconfig-utils": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", + "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", + "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/drizzle-kit": { + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-orm": { + "version": "0.44.7", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.44.7.tgz", + "integrity": "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.387", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", + "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fast-copy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz", + "integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.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" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/npm-run-all/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/npm-run-all/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/npm-run-all/node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/npm-run-all/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/npm-run-all/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-all/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-all/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", + "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.padend": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", + "integrity": "sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsup/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/tsup/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tsup/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz", + "integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.62.1", + "@typescript-eslint/parser": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "packages/shared": { + "name": "@reforger-panel/shared", + "version": "0.1.0" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6e51d8e --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "reforger-panel", + "private": true, + "type": "module", + "version": "0.1.0", + "description": "Private Arma Reforger control panel — Discord login, safe server controls, Workshop integration, and Pterodactyl-based log processing.", + "workspaces": [ + "packages/*", + "apps/*" + ], + "scripts": { + "dev": "npm-run-all --parallel dev:api dev:web", + "dev:api": "npm run dev --workspace @reforger-panel/api", + "dev:web": "npm run dev --workspace @reforger-panel/web", + "build": "npm run build --workspaces --if-present", + "typecheck": "npm run typecheck --workspaces --if-present", + "lint": "eslint .", + "format": "prettier --write .", + "format:check": "prettier --check .", + "test": "npm run test --workspaces --if-present", + "db:generate": "npm run db:generate --workspace @reforger-panel/api", + "db:migrate": "npm run db:migrate --workspace @reforger-panel/api", + "db:seed": "npm run db:seed --workspace @reforger-panel/api" + }, + "devDependencies": { + "@eslint/js": "^9.30.0", + "eslint": "^9.30.0", + "eslint-config-prettier": "^10.1.0", + "npm-run-all": "^4.1.5", + "prettier": "^3.6.0", + "typescript": "^5.8.0", + "typescript-eslint": "^8.35.0" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..6d1f171 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,14 @@ +{ + "name": "@reforger-panel/shared", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..5961bae --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,3 @@ +export * from './roles.js'; +export * from './types.js'; +export * from './reforger-config.js'; diff --git a/packages/shared/src/reforger-config.ts b/packages/shared/src/reforger-config.ts new file mode 100644 index 0000000..1f2fe34 --- /dev/null +++ b/packages/shared/src/reforger-config.ts @@ -0,0 +1,22 @@ +/** + * Typed internal model of the parts of Reforger's server config.json the panel + * cares about. This is NOT the raw config file — generation/deployment of the + * real config.json is a later phase. + */ +export type ReforgerConfigMod = { + modId: string; + name?: string; + version?: string; +}; + +export type ReforgerServerConfig = { + serverName: string; + maxPlayers: number; + scenarioId: string; + aiLimit: number; + serverMaxViewDistance: number; + networkViewDistance: number; + crossPlatform: boolean; + disableThirdPerson: boolean; + mods: ReforgerConfigMod[]; +}; diff --git a/packages/shared/src/roles.ts b/packages/shared/src/roles.ts new file mode 100644 index 0000000..f3a4902 --- /dev/null +++ b/packages/shared/src/roles.ts @@ -0,0 +1,58 @@ +export type Role = 'owner' | 'server_admin' | 'mission_lead' | 'viewer'; + +export const ROLES: readonly Role[] = ['owner', 'server_admin', 'mission_lead', 'viewer']; + +/** + * Fine-grained capabilities. Backend middleware enforces these; the frontend + * only uses them to hide controls the API would reject anyway. + */ +export type Capability = + | 'server.view' + | 'server.power.start' + | 'server.power.stop' + | 'server.power.restart' + | 'mods.manage' + | 'config.edit' + | 'logs.sync' + | 'ops.health.view' + | 'users.manage' + | 'settings.view'; + +const VIEW_ONLY: Capability[] = ['server.view']; + +export const ROLE_CAPABILITIES: Record = { + owner: [ + 'server.view', + 'server.power.start', + 'server.power.stop', + 'server.power.restart', + 'mods.manage', + 'config.edit', + 'logs.sync', + 'ops.health.view', + 'users.manage', + 'settings.view', + ], + server_admin: [ + 'server.view', + 'server.power.start', + 'server.power.stop', + 'server.power.restart', + 'mods.manage', + 'config.edit', + 'ops.health.view', + ], + mission_lead: ['server.view', 'server.power.restart'], + viewer: VIEW_ONLY, +}; + +export function roleHasCapability(role: Role, capability: Capability): boolean { + return ROLE_CAPABILITIES[role].includes(capability); +} + +export const ROLE_LABELS: Record = { + owner: 'Owner', + server_admin: 'Server Admin', + mission_lead: 'Mission Lead', + viewer: 'Viewer', +}; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts new file mode 100644 index 0000000..c5cbb77 --- /dev/null +++ b/packages/shared/src/types.ts @@ -0,0 +1,396 @@ +import type { Capability, Role } from './roles.js'; +import type { ReforgerServerConfig } from './reforger-config.js'; + +// ---------- API envelope ---------- + +export type ApiErrorCode = + | 'UNAUTHENTICATED' + | 'FORBIDDEN' + | 'NOT_FOUND' + | 'VALIDATION_ERROR' + | 'RATE_LIMITED' + | 'CONFLICT' + | 'UPSTREAM_UNAVAILABLE' + | 'NOT_CONFIGURED' + | 'INTERNAL_ERROR'; + +export type ApiErrorBody = { + error: { + code: ApiErrorCode; + message: string; + requestId?: string; + }; +}; + +// ---------- Auth / users ---------- + +export type CurrentUser = { + id: string; + discordId: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + role: Role; + capabilities: Capability[]; +}; + +export type PanelUser = { + id: string; + discordId: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + role: Role; + createdAt: string; + updatedAt: string; +}; + +// ---------- Servers ---------- + +export type ServerStatus = 'online' | 'offline' | 'starting' | 'stopping' | 'unknown'; + +export type ServerSummary = { + id: string; + slug: string; + name: string; + providerType: string; + status: ServerStatus; + maxPlayers: number | null; + onlinePlayerCount: number; + createdAt: string; + updatedAt: string; +}; + +export type ServerResources = { + status: ServerStatus; + cpuPercent: number; + cpuLimitPercent: number | null; + memoryBytes: number; + memoryLimitBytes: number | null; + diskBytes: number; + diskLimitBytes: number | null; + networkRxBytes: number; + networkTxBytes: number; + uptimeMs: number; + fetchedAt: string; +}; + +// ---------- Players ---------- + +export type OnlinePlayer = { + playerId: string; + displayName: string; + externalPlayerId: string | null; + connectedAt: string; + sessionDurationSeconds: number; +}; + +export type PlayersResponse = { + players: OnlinePlayer[]; + onlineCount: number; + maxPlayers: number | null; + lastSyncedAt: string | null; + stale: boolean; +}; + +export type KnownPlayer = { + id: string; + displayName: string; + externalPlayerId: string | null; + firstSeenAt: string; + lastSeenAt: string; + totalSessions: number; + totalPlaytimeSeconds: number; + online: boolean; +}; + +// ---------- Activity ---------- + +export type ActivityItem = { + id: string; + kind: 'panel_action' | 'server_event'; + action: string; + summary: string; + actor: { id: string; username: string; displayName: string | null } | null; + occurredAt: string; +}; + +export type PlayerPosition = { + x: number; + y: number; + z?: number | null; +}; + +export type KillfeedEvent = { + id: string; + occurredAt: string; + killerName: string; + victimName: string; + friendly: boolean; + killerTeam: string | null; + victimTeam: string | null; + killerPosition: PlayerPosition | null; + victimPosition: PlayerPosition | null; + distanceMeters: number | null; + weapon: string | null; +}; + +// ---------- Configuration ---------- + +/** The live config.json, downloaded from the server on request. */ +export type ConfigurationResponse = { + config: ReforgerServerConfig; + fetchedAt: string; +}; + +export type MissionInfo = { + scenarioId: string; + /** Display name from the startup scenario listing, e.g. "Conflict - Everon". */ + name: string; + /** 'official' or the source section header from the log. */ + source: string; +}; + +export type MissionsResponse = { + missions: MissionInfo[]; + /** Null when the current log contains no scenario listing. */ + fetchedAt: string | null; +}; + +export type RawLogsResponse = { + path: string; + lines: string[]; + truncated: boolean; + fetchedAt: string; +}; + +export type StartupVariable = { + name: string; + description: string; + envVariable: string; + value: string; + defaultValue: string; + isEditable: boolean; +}; + +export type StartupResponse = { + variables: StartupVariable[]; + fetchedAt: string; +}; + +// ---------- Schedules ---------- + +export type ServerScheduleTask = { + id: string; + action: 'power' | 'command' | 'backup' | string; + payload: string; + timeOffsetSeconds: number; + continueOnFailure: boolean; +}; + +export type ServerScheduleSummary = { + id: string; + name: string; + isActive: boolean; + onlyWhenOnline: boolean; + minute: string; + hour: string; + dayOfMonth: string; + month: string; + dayOfWeek: string; + nextRunAt: string | null; + lastRunAt: string | null; + createdAt: string | null; + updatedAt: string | null; + tasks: ServerScheduleTask[]; +}; + +export type RestartScheduleInput = { + name: string; + isActive: boolean; + minute: number; + hour: number; + dayOfWeek: '*' | '0' | '1' | '2' | '3' | '4' | '5' | '6'; + onlyWhenOnline: boolean; +}; + +// ---------- Mod packs ---------- + +export type ModPackSummary = { + id: string; + name: string; + description: string | null; + status: string; + modCount: number; + latestVersion: number | null; + updatedAt: string; +}; + +// ---------- Resource history ---------- + +export type ResourceSample = { + /** Unix ms. */ + t: number; + status: ServerStatus; + cpuPercent: number; + cpuLimitPercent: number | null; + memoryBytes: number; + memoryLimitBytes: number | null; + /** Bytes per second, derived from consecutive cumulative counters. */ + networkRxRate: number; + networkTxRate: number; +}; + +export type ResourceHistoryResponse = { + samples: ResourceSample[]; + intervalSeconds: number; +}; + +// ---------- Performance settings (subset of config.json) ---------- + +/** + * Server-performance fields of Reforger's config.json, per + * https://community.bistudio.com/wiki/Arma_Reforger:Server_Config. + * `null` means the key is absent from config.json (game default applies). + */ +export type PerformanceSettings = { + scenarioId: string | null; // game.scenarioId — the running mission + maxPlayers: number | null; // game.maxPlayers, 1–128 (default 64) + serverMaxViewDistance: number | null; // game.gameProperties, 500–10000 (default 1600) + networkViewDistance: number | null; // game.gameProperties, 500–5000 (default 1500) + serverMinGrassDistance: number | null; // game.gameProperties, 0–150 (default 0) + disableThirdPerson: boolean | null; // game.gameProperties (default false) + fastValidation: boolean | null; // game.gameProperties (default true) + battlEye: boolean | null; // game.gameProperties (default true) + aiLimit: number | null; // operating, -1 = unlimited (default -1) + playerSaveTime: number | null; // operating, seconds (default 120) + slotReservationTimeout: number | null; // operating, 5–300 s (default 60) + lobbyPlayerSynchronise: boolean | null; // operating (default true) +}; + +export type PerformanceSettingsResponse = { + settings: PerformanceSettings; + fetchedAt: string; +}; + +/** PUT body: only the provided keys are touched; null removes the key. */ +export type PerformanceSettingsPatch = Partial; + +// ---------- Invites ---------- + +export type InviteSummary = { + id: string; + code: string; + role: Role; + createdBy: string | null; + expiresAt: string; + usedBy: string | null; + usedAt: string | null; + createdAt: string; +}; + +// ---------- Server mods (game.mods in config.json) ---------- + +export type ServerModsResponse = { + mods: { modId: string; name?: string; version?: string }[]; + /** When the config.json this list came from was downloaded. */ + fetchedAt: string; +}; + +export type UpdateModsResult = ServerModsResponse & { + added: number; + removed: number; + /** Reforger only picks up config changes on the next server restart. */ + requiresRestart: true; +}; + +// ---------- Workshop ---------- + +export type WorkshopHealth = { + ok: boolean; + latencyMs: number | null; + checkedAt: string; + message: string | null; +}; + +export type WorkshopModPreview = { + id: string; + name: string; + author: string; + imageUrl: string | null; + size: string | null; + rating: string | null; + workshopUrl: string | null; +}; + +export type WorkshopSearchResponse = { + mods: WorkshopModPreview[]; + meta: { + totalPages: number; + currentPage: number; + totalMods: number; + }; +}; + +export type WorkshopScenario = { + name: string; + description: string | null; + scenarioId: string; + gamemode: string | null; + playerCount: number | null; + imageUrl: string | null; +}; + +export type WorkshopModDetail = WorkshopModPreview & { + version: string | null; + gameVersion: string | null; + subscribers: number | null; + downloads: number | null; + createdAtText: string | null; + lastModifiedText: string | null; + summary: string | null; + description: string | null; + license: string | null; + tags: string[]; + dependencies: { name: string; id: string | null }[]; + scenarios: WorkshopScenario[]; +}; + +// ---------- Log ingestion ---------- + +export type ServerEventType = + | 'player_connected' + | 'player_disconnected' + | 'player_killed' + | 'server_started' + | 'server_stopped' + | 'server_restart_detected' + | 'log_sync_completed' + | 'log_sync_failed'; + +export type LogSyncResult = { + serverId: string; + logPath: string; + fetchedBytes: number; + processedLines: number; + createdEvents: number; + updatedSessions: number; + cursorReset: boolean; + startedAt: string; + finishedAt: string; +}; + +export type LogIngestionHealth = { + configured: boolean; + running: boolean; + logPath: string | null; + lastSuccessfulSyncAt: string | null; + lastErrorAt: string | null; + lastErrorMessage: string | null; + lastSync: { + processedLines: number; + createdEvents: number; + updatedSessions: number; + } | null; + stale: boolean; +}; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..32a1de5 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..76b03d9 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "sourceMap": true + } +}