From ce8f719a05c392948bde9f959ac09795574672ae Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 5 Jul 2026 16:54:59 -0700 Subject: [PATCH] initial commit --- .dockerignore | 9 + .env.example | 52 + .gitignore | 10 + .prettierignore | 6 + .prettierrc.json | 7 + Dockerfile | 30 + README.md | 183 + apps/api/drizzle.config.ts | 10 + apps/api/drizzle/0000_init.sql | 152 + apps/api/drizzle/0001_invites.sql | 14 + apps/api/drizzle/meta/0000_snapshot.json | 1142 ++ apps/api/drizzle/meta/0001_snapshot.json | 1308 +++ apps/api/drizzle/meta/_journal.json | 20 + apps/api/package.json | 37 + apps/api/src/app.ts | 166 + apps/api/src/db/client.ts | 12 + apps/api/src/db/migrate.ts | 17 + apps/api/src/db/schema.ts | 247 + apps/api/src/db/seed.ts | 47 + apps/api/src/env.ts | 105 + apps/api/src/index.ts | 163 + apps/api/src/lib/crypto.ts | 31 + apps/api/src/lib/errors.ts | 47 + apps/api/src/lib/logger.ts | 48 + apps/api/src/lib/rate-limit.ts | 33 + apps/api/src/modules/auth/auth-middleware.ts | 89 + apps/api/src/modules/auth/auth-routes.ts | 162 + apps/api/src/modules/auth/discord.ts | 82 + apps/api/src/modules/auth/session-service.ts | 121 + .../src/modules/config/config-file-gateway.ts | 66 + apps/api/src/modules/config/config-sync.ts | 68 + apps/api/src/modules/config/mods-service.ts | 96 + .../src/modules/config/performance-service.ts | 145 + .../config/reforger-config-file.test.ts | 76 + .../modules/config/reforger-config-file.ts | 74 + apps/api/src/modules/invites/invite-routes.ts | 152 + .../src/modules/pterodactyl/mock-provider.ts | 342 + .../pterodactyl/pterodactyl-provider.ts | 483 + apps/api/src/modules/pterodactyl/types.ts | 86 + .../ingestion/cursor-service.test.ts | 124 + .../reforger-logs/ingestion/cursor-service.ts | 145 + .../reforger-logs/ingestion/drizzle-store.ts | 196 + .../ingestion/ingestion-service.ts | 371 + .../ingestion/log-path-resolver.test.ts | 92 + .../ingestion/log-path-resolver.ts | 73 + .../ingestion/pterodactyl-log-source.ts | 15 + .../reforger-logs/ingestion/scheduler.ts | 120 + .../modules/reforger-logs/ingestion/types.ts | 88 + .../reforger-logs/missions-catalog.test.ts | 95 + .../modules/reforger-logs/missions-catalog.ts | 102 + .../reforger-logs/parser/parser.test.ts | 182 + .../modules/reforger-logs/parser/parser.ts | 217 + .../modules/reforger-logs/parser/patterns.ts | 57 + .../src/modules/reforger-logs/parser/types.ts | 63 + .../src/modules/servers/resource-history.ts | 103 + apps/api/src/modules/servers/server-routes.ts | 687 ++ .../api/src/modules/servers/server-service.ts | 259 + apps/api/src/modules/users/user-routes.ts | 60 + .../modules/workshop/workshop-client.test.ts | 108 + .../src/modules/workshop/workshop-client.ts | 282 + .../src/modules/workshop/workshop-routes.ts | 57 + apps/api/test/auth-routes.test.ts | 426 + .../test/helpers/in-memory-ingestion-store.ts | 165 + apps/api/test/ingestion-service.test.ts | 229 + apps/api/test/mods-service.test.ts | 108 + apps/api/test/performance-service.test.ts | 77 + apps/api/test/providers.test.ts | 137 + apps/api/tsconfig.json | 8 + apps/api/tsup.config.ts | 11 + apps/api/vitest.config.ts | 8 + apps/web/index.html | 12 + apps/web/package.json | 28 + apps/web/src/App.tsx | 89 + apps/web/src/api/client.ts | 55 + apps/web/src/api/hooks.ts | 371 + apps/web/src/components/charts.tsx | 81 + apps/web/src/components/invites-card.tsx | 145 + apps/web/src/components/layout.tsx | 140 + apps/web/src/components/mission-card.tsx | 108 + apps/web/src/components/performance-form.tsx | 211 + apps/web/src/components/schedules-card.tsx | 255 + apps/web/src/components/startup-vars-card.tsx | 117 + apps/web/src/components/ui.tsx | 162 + apps/web/src/components/widgets.tsx | 343 + apps/web/src/index.css | 124 + apps/web/src/lib/format.ts | 47 + apps/web/src/main.tsx | 10 + apps/web/src/pages/login.tsx | 85 + apps/web/src/pages/logs.tsx | 90 + apps/web/src/pages/mods.tsx | 435 + apps/web/src/pages/overview.tsx | 181 + apps/web/src/pages/simple-pages.tsx | 372 + apps/web/tsconfig.json | 13 + apps/web/vite.config.ts | 16 + docker-compose.prod.yml | 38 + docker-compose.yml | 22 + eslint.config.js | 21 + package-lock.json | 9855 +++++++++++++++++ package.json | 37 + packages/shared/package.json | 14 + packages/shared/src/index.ts | 3 + packages/shared/src/reforger-config.ts | 22 + packages/shared/src/roles.ts | 58 + packages/shared/src/types.ts | 396 + packages/shared/tsconfig.json | 7 + tsconfig.base.json | 18 + 106 files changed, 24584 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 apps/api/drizzle.config.ts create mode 100644 apps/api/drizzle/0000_init.sql create mode 100644 apps/api/drizzle/0001_invites.sql create mode 100644 apps/api/drizzle/meta/0000_snapshot.json create mode 100644 apps/api/drizzle/meta/0001_snapshot.json create mode 100644 apps/api/drizzle/meta/_journal.json create mode 100644 apps/api/package.json create mode 100644 apps/api/src/app.ts create mode 100644 apps/api/src/db/client.ts create mode 100644 apps/api/src/db/migrate.ts create mode 100644 apps/api/src/db/schema.ts create mode 100644 apps/api/src/db/seed.ts create mode 100644 apps/api/src/env.ts create mode 100644 apps/api/src/index.ts create mode 100644 apps/api/src/lib/crypto.ts create mode 100644 apps/api/src/lib/errors.ts create mode 100644 apps/api/src/lib/logger.ts create mode 100644 apps/api/src/lib/rate-limit.ts create mode 100644 apps/api/src/modules/auth/auth-middleware.ts create mode 100644 apps/api/src/modules/auth/auth-routes.ts create mode 100644 apps/api/src/modules/auth/discord.ts create mode 100644 apps/api/src/modules/auth/session-service.ts create mode 100644 apps/api/src/modules/config/config-file-gateway.ts create mode 100644 apps/api/src/modules/config/config-sync.ts create mode 100644 apps/api/src/modules/config/mods-service.ts create mode 100644 apps/api/src/modules/config/performance-service.ts create mode 100644 apps/api/src/modules/config/reforger-config-file.test.ts create mode 100644 apps/api/src/modules/config/reforger-config-file.ts create mode 100644 apps/api/src/modules/invites/invite-routes.ts create mode 100644 apps/api/src/modules/pterodactyl/mock-provider.ts create mode 100644 apps/api/src/modules/pterodactyl/pterodactyl-provider.ts create mode 100644 apps/api/src/modules/pterodactyl/types.ts create mode 100644 apps/api/src/modules/reforger-logs/ingestion/cursor-service.test.ts create mode 100644 apps/api/src/modules/reforger-logs/ingestion/cursor-service.ts create mode 100644 apps/api/src/modules/reforger-logs/ingestion/drizzle-store.ts create mode 100644 apps/api/src/modules/reforger-logs/ingestion/ingestion-service.ts create mode 100644 apps/api/src/modules/reforger-logs/ingestion/log-path-resolver.test.ts create mode 100644 apps/api/src/modules/reforger-logs/ingestion/log-path-resolver.ts create mode 100644 apps/api/src/modules/reforger-logs/ingestion/pterodactyl-log-source.ts create mode 100644 apps/api/src/modules/reforger-logs/ingestion/scheduler.ts create mode 100644 apps/api/src/modules/reforger-logs/ingestion/types.ts create mode 100644 apps/api/src/modules/reforger-logs/missions-catalog.test.ts create mode 100644 apps/api/src/modules/reforger-logs/missions-catalog.ts create mode 100644 apps/api/src/modules/reforger-logs/parser/parser.test.ts create mode 100644 apps/api/src/modules/reforger-logs/parser/parser.ts create mode 100644 apps/api/src/modules/reforger-logs/parser/patterns.ts create mode 100644 apps/api/src/modules/reforger-logs/parser/types.ts create mode 100644 apps/api/src/modules/servers/resource-history.ts create mode 100644 apps/api/src/modules/servers/server-routes.ts create mode 100644 apps/api/src/modules/servers/server-service.ts create mode 100644 apps/api/src/modules/users/user-routes.ts create mode 100644 apps/api/src/modules/workshop/workshop-client.test.ts create mode 100644 apps/api/src/modules/workshop/workshop-client.ts create mode 100644 apps/api/src/modules/workshop/workshop-routes.ts create mode 100644 apps/api/test/auth-routes.test.ts create mode 100644 apps/api/test/helpers/in-memory-ingestion-store.ts create mode 100644 apps/api/test/ingestion-service.test.ts create mode 100644 apps/api/test/mods-service.test.ts create mode 100644 apps/api/test/performance-service.test.ts create mode 100644 apps/api/test/providers.test.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/api/tsup.config.ts create mode 100644 apps/api/vitest.config.ts create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/api/client.ts create mode 100644 apps/web/src/api/hooks.ts create mode 100644 apps/web/src/components/charts.tsx create mode 100644 apps/web/src/components/invites-card.tsx create mode 100644 apps/web/src/components/layout.tsx create mode 100644 apps/web/src/components/mission-card.tsx create mode 100644 apps/web/src/components/performance-form.tsx create mode 100644 apps/web/src/components/schedules-card.tsx create mode 100644 apps/web/src/components/startup-vars-card.tsx create mode 100644 apps/web/src/components/ui.tsx create mode 100644 apps/web/src/components/widgets.tsx create mode 100644 apps/web/src/index.css create mode 100644 apps/web/src/lib/format.ts create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/pages/login.tsx create mode 100644 apps/web/src/pages/logs.tsx create mode 100644 apps/web/src/pages/mods.tsx create mode 100644 apps/web/src/pages/overview.tsx create mode 100644 apps/web/src/pages/simple-pages.tsx create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 eslint.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/reforger-config.ts create mode 100644 packages/shared/src/roles.ts create mode 100644 packages/shared/src/types.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 tsconfig.base.json 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 + } +}