From 640e52c0c91342e2106d22d1b9513aa12becd694 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sun, 1 Nov 2026 22:44:30 -0300 Subject: [PATCH] Initial commit: CraftDock as of 2026-11-01 The previous GitHub history was lost with the account (the local .git was missing its refs and most objects), so this repository starts from the existing code. Co-Authored-By: Claude Sonnet 5.5 --- .dockerignore | 7 + .env.example | 33 + .gitattributes | 1 + .gitignore | 10 + .trivyignore | 9 + CLAUDE.md | 123 + Dockerfile | 47 + LICENSE | 21 + README.md | 270 + docker-compose.bedrock.yaml | 79 + docker-compose.java.yaml | 82 + package-lock.json | 4899 ++++++++++++++++++ package.json | 40 + scripts/extract-mc-assets.mjs | 59 + scripts/hash-password.js | 20 + src/adapters/bedrockAdapter.js | 92 + src/adapters/index.js | 12 + src/adapters/javaAdapter.js | 58 + src/adapters/playerFile/bedrockPlayerFile.js | 179 + src/adapters/playerFile/index.js | 11 + src/adapters/playerFile/javaPlayerFile.js | 25 + src/adapters/playerFile/nbtPlayer.js | 104 + src/adapters/serverAdapter.js | 62 + src/appState.js | 87 + src/config.js | 46 + src/healthcheck.js | 41 + src/logger.js | 77 + src/main.js | 93 + src/middleware/auth.js | 15 + src/public/css/styles.css | 64 + src/public/index.html | 85 + src/public/js/app.js | 87 + src/public/js/socket.js | 17 + src/public/js/tabs/console.js | 59 + src/public/js/tabs/map.js | 91 + src/public/js/tabs/options.js | 271 + src/public/js/tabs/players.js | 381 ++ src/public/js/tabs/status.js | 115 + src/public/js/tabs/world.js | 237 + src/routes/auth.js | 22 + src/routes/players.js | 140 + src/routes/properties.js | 75 + src/routes/status.js | 40 + src/routes/world.js | 32 + src/server.js | 144 + src/services/authService.js | 10 + src/services/bedrockIdentityBridge.js | 62 + src/services/dockerService.js | 172 + src/services/firstBootService.js | 33 + src/services/playerDetailService.js | 98 + src/services/playerDirectory.js | 208 + src/services/propertiesService.js | 53 + src/services/rconService.js | 50 + src/services/seedService.js | 93 + src/services/stdinService.js | 115 + src/services/worldService.js | 197 + src/sockets/index.js | 17 + src/sockets/logsSocket.js | 26 + src/sockets/statusSocket.js | 68 + test/adapters/bedrockAdapter.test.js | 184 + test/adapters/bedrockPlayerFile.test.js | 102 + test/adapters/index.test.js | 26 + test/adapters/javaAdapter.test.js | 108 + test/adapters/javaPlayerFile.test.js | 57 + test/adapters/nbtPlayer.test.js | 104 + test/adapters/playerFileIndex.test.js | 18 + test/adapters/serverAdapter.test.js | 62 + test/appState.test.js | 51 + test/config.test.js | 56 + test/fixtures/bedrock-player.nbt | Bin 0 -> 10105 bytes test/logger.test.js | 57 + test/middleware/auth.test.js | 61 + test/routes/auth.test.js | 37 + test/routes/players.test.js | 149 + test/routes/properties.test.js | 82 + test/routes/status.test.js | 54 + test/routes/world.test.js | 57 + test/server.integration.test.js | 40 + test/services/authService.test.js | 23 + test/services/bedrockIdentityBridge.test.js | 49 + test/services/dockerService.test.js | 61 + test/services/dockerServiceResolve.test.js | 52 + test/services/dockerServiceStats.test.js | 57 + test/services/dockerServiceStreams.test.js | 38 + test/services/firstBootService.test.js | 49 + test/services/playerDetailService.test.js | 97 + test/services/playerDirectory.test.js | 108 + test/services/propertiesService.test.js | 48 + test/services/rconService.test.js | 30 + test/services/seedService.test.js | 56 + test/services/stdinService.test.js | 113 + test/services/worldServiceBackup.test.js | 70 + test/services/worldServiceDownload.test.js | 36 + test/services/worldServiceRegen.test.js | 46 + test/services/worldServiceUpload.test.js | 93 + test/sockets/index.test.js | 22 + test/sockets/logsSocket.test.js | 56 + test/sockets/statusSocket.test.js | 33 + vitest.config.js | 9 + 99 files changed, 12115 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .trivyignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docker-compose.bedrock.yaml create mode 100644 docker-compose.java.yaml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/extract-mc-assets.mjs create mode 100644 scripts/hash-password.js create mode 100644 src/adapters/bedrockAdapter.js create mode 100644 src/adapters/index.js create mode 100644 src/adapters/javaAdapter.js create mode 100644 src/adapters/playerFile/bedrockPlayerFile.js create mode 100644 src/adapters/playerFile/index.js create mode 100644 src/adapters/playerFile/javaPlayerFile.js create mode 100644 src/adapters/playerFile/nbtPlayer.js create mode 100644 src/adapters/serverAdapter.js create mode 100644 src/appState.js create mode 100644 src/config.js create mode 100644 src/healthcheck.js create mode 100644 src/logger.js create mode 100644 src/main.js create mode 100644 src/middleware/auth.js create mode 100644 src/public/css/styles.css create mode 100644 src/public/index.html create mode 100644 src/public/js/app.js create mode 100644 src/public/js/socket.js create mode 100644 src/public/js/tabs/console.js create mode 100644 src/public/js/tabs/map.js create mode 100644 src/public/js/tabs/options.js create mode 100644 src/public/js/tabs/players.js create mode 100644 src/public/js/tabs/status.js create mode 100644 src/public/js/tabs/world.js create mode 100644 src/routes/auth.js create mode 100644 src/routes/players.js create mode 100644 src/routes/properties.js create mode 100644 src/routes/status.js create mode 100644 src/routes/world.js create mode 100644 src/server.js create mode 100644 src/services/authService.js create mode 100644 src/services/bedrockIdentityBridge.js create mode 100644 src/services/dockerService.js create mode 100644 src/services/firstBootService.js create mode 100644 src/services/playerDetailService.js create mode 100644 src/services/playerDirectory.js create mode 100644 src/services/propertiesService.js create mode 100644 src/services/rconService.js create mode 100644 src/services/seedService.js create mode 100644 src/services/stdinService.js create mode 100644 src/services/worldService.js create mode 100644 src/sockets/index.js create mode 100644 src/sockets/logsSocket.js create mode 100644 src/sockets/statusSocket.js create mode 100644 test/adapters/bedrockAdapter.test.js create mode 100644 test/adapters/bedrockPlayerFile.test.js create mode 100644 test/adapters/index.test.js create mode 100644 test/adapters/javaAdapter.test.js create mode 100644 test/adapters/javaPlayerFile.test.js create mode 100644 test/adapters/nbtPlayer.test.js create mode 100644 test/adapters/playerFileIndex.test.js create mode 100644 test/adapters/serverAdapter.test.js create mode 100644 test/appState.test.js create mode 100644 test/config.test.js create mode 100644 test/fixtures/bedrock-player.nbt create mode 100644 test/logger.test.js create mode 100644 test/middleware/auth.test.js create mode 100644 test/routes/auth.test.js create mode 100644 test/routes/players.test.js create mode 100644 test/routes/properties.test.js create mode 100644 test/routes/status.test.js create mode 100644 test/routes/world.test.js create mode 100644 test/server.integration.test.js create mode 100644 test/services/authService.test.js create mode 100644 test/services/bedrockIdentityBridge.test.js create mode 100644 test/services/dockerService.test.js create mode 100644 test/services/dockerServiceResolve.test.js create mode 100644 test/services/dockerServiceStats.test.js create mode 100644 test/services/dockerServiceStreams.test.js create mode 100644 test/services/firstBootService.test.js create mode 100644 test/services/playerDetailService.test.js create mode 100644 test/services/playerDirectory.test.js create mode 100644 test/services/propertiesService.test.js create mode 100644 test/services/rconService.test.js create mode 100644 test/services/seedService.test.js create mode 100644 test/services/stdinService.test.js create mode 100644 test/services/worldServiceBackup.test.js create mode 100644 test/services/worldServiceDownload.test.js create mode 100644 test/services/worldServiceRegen.test.js create mode 100644 test/services/worldServiceUpload.test.js create mode 100644 test/sockets/index.test.js create mode 100644 test/sockets/logsSocket.test.js create mode 100644 test/sockets/statusSocket.test.js create mode 100644 vitest.config.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..54a0095 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.git +.env +test +*.md +coverage +src/public/assets/mc diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b2f8781 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# CraftDock environment — every variable the app reads (see src/config.js). +# Copy to .env and fill in. Full descriptions are in the README env-var table. + +# --- Auth (required) --- +# Generate both forms with: npm run hash -- . Set exactly ONE. +# ADMIN_PASSWORD_HASH raw bcrypt hash — use with `npm run dev` (plain .env) +# ADMIN_PASSWORD_HASH_B64 base64 of the hash — use with docker compose / Coolify +# (bcrypt hashes contain `$`, which those interpolate) +ADMIN_PASSWORD_HASH= +ADMIN_PASSWORD_HASH_B64= +SESSION_SECRET=change-me-to-a-long-random-string + +# --- Minecraft target --- +MC_CONTAINER_NAME=craftdock-mc-server # container name (matched first) +MC_SERVICE_NAME=minecraft-server # compose service name (fallback when the + # container name is auto-generated, e.g. Coolify) +MC_DATA_PATH=/minecraft/data # shared mc-data volume path inside the panel +MC_EDITION=auto # auto | java | bedrock +MC_WORLD_NAME=world # world folder under MC_DATA_PATH (Bedrock: worlds) + +# --- RCON (Java only; leave empty for Bedrock) --- +RCON_HOST=craftdock-mc-server +RCON_PORT=25575 +RCON_PASSWORD= + +# --- Map tab (optional) --- +MAP_VERSION= # mcseedmap version override, e.g. 1.21-Java or 26.30.0-Bedrock + +# --- App --- +PORT=3000 +NODE_ENV=production # use `development` for plain-http local testing +LOG_LEVEL=info # error | warn | info | debug +MAX_UPLOAD_MB=1024 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52b030d --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.env +panel-data/ +*.log +coverage/ +docs/ +.superpowers/ +world.zip +logs.txt +src/public/assets/mc/ diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000..363eb65 --- /dev/null +++ b/.trivyignore @@ -0,0 +1,9 @@ +# DS-0002: "Specify at least 1 USER command in Dockerfile with non-root user". +# Accepted risk, deliberately: CraftDock's entire purpose is to control the +# Minecraft container through /var/run/docker.sock (root-equivalent by design) +# and to read/write world files the game server creates with mixed ownership. +# Running as a non-root user would break the docker.sock connection on most +# hosts (socket is root:docker with a host-specific GID) without reducing the +# real attack surface — the socket mount, not the container user, is the power. +# Access is gated by the panel's bcrypt-authenticated session on every route. +DS-0002 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5936847 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,123 @@ +# CLAUDE.md + +Guidance for Claude Code when working in this repository. + +## What CraftDock is + +A lightweight, self-hosted web panel that manages a **single** Minecraft server (Java *or* Bedrock) running as a Docker container. Node.js + Express + Socket.io backend, vanilla-JS tabbed SPA frontend (Tailwind via CDN). The panel never modifies the Minecraft core: it controls the game container over the Docker socket and sends commands via RCON (Java) or container stdin (Bedrock). + +## Commands + +```bash +npm test # full Vitest suite — must pass before any commit +npm run test:watch +npm run dev # dev server (loads .env, watches files), panel on :3000 +npm run hash -- # generate the admin bcrypt hash (raw + base64 forms) +docker compose -f docker-compose.bedrock.yaml up -d --build # full Bedrock stack +docker compose -f docker-compose.java.yaml up -d --build # full Java stack +``` + +Run a single test file: `npx vitest run test/services/playerDetailService.test.js`. + +## Git workflow (mandatory) + +- **One numbered branch per feature/fix**: `feature/N-slug` or `fix/N-slug`, where N is sequential across the repo (check `git branch -a` for the highest existing N). +- **Every commit message is prefixed with the branch number**: `#N: message` (e.g. `#10: resolve Bedrock player detail via player_ ServerId mapping`). This makes it trivial to see which branch a change came from. +- **Merge into `main` with `--no-ff`** so the graph shows one merge bubble per feature. Never commit directly to `main`. +- **Squash small commits**: repeated touch-ups to the same file or tiny adjustments belong inside one larger, complete commit — don't inflate the tree. +- Messages in English, imperative, descriptive of the *why* when non-obvious. + +## Architecture + +### Composition root + dependency injection + +`src/main.js` is the only place that wires real dependencies; `src/server.js` composes the Express app + Socket.io namespaces from injected pieces. Every service/route/adapter is a **factory function taking its collaborators as arguments** (`createXxx({ dep1, dep2 })`), so unit tests inject fakes — never module-level singletons for anything that touches I/O. + +### Layers + +``` +src/ +├── main.js # composition root (real deps wired here, and only here) +├── server.js # Express + Socket.io composition (takes deps) +├── config.js # env loading + validation (single source of env truth) +├── logger.js # structured JSON logger (ts/level/component/msg/meta) +├── appState.js # cached edition detection + adapter resolution +├── middleware/auth.js # session auth for HTTP routes AND socket handshake +├── adapters/ # ServerAdapter interface: java (RCON) / bedrock (stdin) +│ └── playerFile/ # read-only world-save readers (see below) +├── services/ # docker, rcon, stdin, properties, world, seed, auth, +│ # playerDirectory, playerDetailService, bedrockIdentityBridge +├── routes/ # auth, status, players, properties, world (Express routers) +├── sockets/ # logs stream + status push namespaces +└── public/ # vanilla-JS SPA: js/app.js shell + js/tabs/*.js (one file per tab) +``` + +### The adapter pattern (edition differences) + +`ServerAdapter` abstracts the two editions behind one interface. Capability differences are declared, not special-cased in routes/UI: + +| | Java | Bedrock | +|---|---|---| +| Command channel | RCON (`rconService`) | container stdin via `docker attach` (`stdinService`) | +| whitelist | `whitelist ...` | `allowlist ...` | +| ban/pardon | supported | **no native command** → `NotSupportedError`, buttons hidden | +| seed | `seed` command | **no command** — read `RandomSeed` from `level.dat` (`seedService`) | +| save snapshot | `save-all flush` | `save hold` → poll `save query` → read → `save resume` | + +The frontend gets `capabilities` from `/api/players` and only renders controls the edition supports. When adding a command, add it to both adapters (or throw `NotSupportedError`) and update `CAPABILITIES` in `serverAdapter.js`. + +### Player data: read-only from world saves + +**Rule: ALL player data (position, health, food, inventory, gamemode, XP) is read directly from the world save files, read-only — never via console commands.** Per-edition file adapters in `src/adapters/playerFile/`: + +- `nbtPlayer.js` — normalizes decoded NBT into one edition-agnostic `PlayerData` shape. +- `javaPlayerFile.js` — reads `world/playerdata/.dat` (gzip NBT, big-endian). +- `bedrockPlayerFile.js` — reads the world **LevelDB** (`leveldb-zlib`, Mojang variant, little-endian NBT). + +While the server runs, reads happen inside a save snapshot (`save hold`/`save-all flush`) orchestrated by `playerDetailService` — never open the live LevelDB without it. + +### Bedrock identity model (critical, hard-won knowledge) + +Bedrock never stores the gamertag or XUID readably inside the world DB. Three identity layers exist: + +1. **XUID + gamertag** — only appear in the server log (`Player Spawned: xuid: `). `playerDirectory.js` scans docker logs for these lines and persists an XUID-keyed directory at `/craftdock_players.json` (Java seeds from `usercache.json` instead). +2. **`uniqueId` UUID** (MsaId/SelfSignedId) — returned by the `querytarget` console command for an *online* player. ⚠️ `querytarget` also returns an int64 `id` (the entity UniqueID); do not confuse the two. +3. **LevelDB records** — `player_` mapping records (keyed by MsaId or SelfSignedId) whose NBT `ServerId` field points to the actual data record `player_server_`. + +`bedrockIdentityBridge.js` learns and persists the XUID → leveldbUuid binding at `/craftdock_bedrock_ids.json` (learned once while the player is online, works offline forever after). Resolution order in `bedrockPlayerFile.findByUniqueId`: direct `player_` mapping lookup first, NBT `UniqueID` scan as fallback. + +### Properties (Options tab) + +`PROPERTY_SCHEMA` in `src/routes/properties.js` is the single source of managed keys: type-based validation on PUT **and** vanilla defaults merged into GET (so the UI always starts fully populated; `motd` deliberately has no default). The frontend `options.js` renders from its own `CATEGORIES` list — when adding a property, update both the schema (backend) and CATEGORIES (frontend). + +### Item icon assets (generated, never committed) + +The player inspector renders Minecraft item icons served from `src/public/assets/mc/items/` — that directory is **gitignored and generated**: `scripts/extract-mc-assets.mjs` copies flat item PNGs out of the `minecraft-assets` devDependency. It runs automatically via `postinstall` for local dev, and in the Dockerfile `assets` stage at image build time (no runtime download — the panel stays offline-capable). Bump the game version with the `ASSETS_VERSION` build arg or `npm run assets -- `. Unknown/modded items fall back to a text tile in the UI. + +### CraftDock metadata files + +Panel-owned files live at the data root, siblings of the world, so they ride along in world exports/backups: `craftdock_players.json` (XUID directory), `craftdock_bedrock_ids.json` (identity bridge). + +## Testing rules + +- **Vitest**, tests mirror `src/` under `test/`. TDD: write the failing test before the fix/feature. +- Unit tests inject fake collaborators (plain objects with `vi.fn()`); route tests use `supertest` against a minimal Express app. +- `test/fixtures/bedrock-player.nbt` is a **real captured Bedrock player NBT**; the bedrockPlayerFile tests build a real LevelDB in a temp dir around it. Prefer real-format fixtures over hand-mocked parsing. +- The whole suite must stay green (`npm test`) before committing. + +## Environment & deployment facts + +- Panel listens on **3000** (config default). The composes only `expose` it (reverse-proxy first); publish on the host only for local testing. Under Coolify the service's target port must be 3000. +- Auth: single admin password; prefer `ADMIN_PASSWORD_HASH_B64` under docker compose/Coolify (bcrypt `$` gets eaten by interpolation). `SESSION_SECRET` signs the cookie; `NODE_ENV=production` marks it `secure` (needs HTTPS). +- The Minecraft service **must** have `stdin_open: true` + `tty: true` (stdin channel + non-multiplexed logs) and `OVERRIDE_SERVER_PROPERTIES=false` (so the Options tab persists). +- The `mc-data` volume is shared panel ↔ game container; `MC_DATA_PATH` points at it inside the panel. +- Edition: `MC_EDITION=auto|java|bedrock` (auto reads the container's `TYPE` env). `MC_WORLD_NAME` is `world` (Java) / `worlds` (Bedrock). +- Local integration testing: the maintainer runs the stacks via **WSL docker**; the world lives in the `craftdock_mc-data` volume. To inspect real world data safely, stop the containers and copy it out (e.g. `docker run --rm -v craftdock_mc-data:/d alpine tar cf - -C /d worlds`). + +## Code style + +- ES modules (`"type": "module"`), Node ≥ 18, no build step, no TypeScript. +- Factory functions + closures over classes. Small focused files — split before a file grows into multiple responsibilities. +- Frontend: no framework, no bundler; each tab exports `render(root)` and returns an optional cleanup function. Escape all user-derived strings with the local `esc()` before injecting into HTML. +- Comments explain *why*/constraints (protocol quirks, Bedrock oddities), not what the next line does. +- UI text is Brazilian Portuguese; code, comments and commits are English. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dc0f9b1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +# Multi-stage: compile native deps on Debian (glibc) where leveldb-zlib's +# cmake-js build is well-supported, then ship a slim runtime. +# +# Why not Alpine? `leveldb-zlib` is a native addon that compiles from source via +# cmake-js. On Alpine/musl (esp. ARM64) that build repeatedly failed at the CMake +# configure step; Debian glibc is the reliable path for this toolchain. +FROM node:20-bookworm-slim AS builder +WORKDIR /app +COPY package*.json ./ +# scripts/ must exist before npm ci: the postinstall hook runs the (no-op here) +# icon extraction. --ignore-scripts is NOT an option — leveldb-zlib compiles +# through its own install scripts. +COPY scripts ./scripts +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ cmake zlib1g-dev ca-certificates \ + && npm ci --omit=dev + +# Item icons are generated at build time (not committed, not downloaded at +# runtime): install the versioned minecraft-assets package and extract the flat +# item PNGs. Bump ASSETS_VERSION (or rebuild) to pick up new game items. +FROM node:20-bookworm-slim AS assets +ARG ASSETS_VERSION=latest +WORKDIR /app +COPY scripts/extract-mc-assets.mjs scripts/ +RUN npm install --no-save --ignore-scripts minecraft-assets@${ASSETS_VERSION} \ + && node scripts/extract-mc-assets.mjs --required + +FROM node:20-bookworm-slim +WORKDIR /app +# Patch fixable OS CVEs in the base image at build time (trivy image scan). +RUN apt-get update \ + && apt-get upgrade -y \ + && rm -rf /var/lib/apt/lists/* +# The compiled node-leveldb.node links libz (zlib1g) and libstdc++6, both already +# present in the slim base image — so no extra runtime packages are needed. +COPY --from=builder /app/node_modules ./node_modules +COPY package*.json ./ +COPY src ./src +COPY scripts ./scripts +COPY --from=assets /app/src/public/assets/mc ./src/public/assets/mc +ENV NODE_ENV=production +EXPOSE 3000 +# Composes may override with their own healthcheck block; this is the built-in +# default so the image is self-monitoring anywhere it runs (trivy DS-0026). +HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \ + CMD ["node", "src/healthcheck.js"] +CMD ["node", "src/main.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fc8366e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lucas Rayan Guerra + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1805994 --- /dev/null +++ b/README.md @@ -0,0 +1,270 @@ +# CraftDock + +Lightweight, self-hosted web panel to manage a **single** Minecraft server (Java *or* Bedrock) running as a Docker container — an Aternos-style dashboard you host yourself. Built for a Docker/Coolify deployment. + +The panel never modifies the Minecraft core. It controls the game container over the Docker socket (start/stop/restart/kill, stats, logs) and sends commands through **RCON** (Java) or **container stdin** (Bedrock). + +--- + +## Features + +| Tab | What it does | +|-----|--------------| +| **Início / Status** | Start / Stop / Restart / Kill, live CPU / RAM / player count, edition badge | +| **Console** | Real-time log stream over WebSocket + command input (RCON / stdin) | +| **Jogadores** | Persistent world player directory (paginated, searchable), per-player **inspector modal** (position, health, food, inventory, XP, gamemode — read straight from the world save), whitelist / ban / op management, kick & teleport — controls adapt to what the edition supports | +| **Opções** | Friendly editor for `server.properties` (difficulty, PvP, max-players, gamemode, …) — keys missing from the file are pre-filled with their vanilla defaults | +| **Mundo** | Download the world as `.zip`, upload/replace a world, regenerate a new world | +| **Mapa** | Shows the world seed and embeds a seed map (mcseedmap.net) rendered for that seed | + +--- + +## Editions + +CraftDock supports both Minecraft editions through an adapter layer, but they are **not** equivalent: + +- **Java** — commands go over **RCON**. All player controls work: whitelist, ban/pardon, op/deop, give, gamemode, kick, teleport. +- **Bedrock** — commands go over the container's **stdin** (`docker attach`). `whitelist` becomes `allowlist`, and **ban / pardon have no native command** — those buttons are hidden in the UI. + +The edition is detected from the container's `TYPE` env var when `MC_EDITION=auto`, or forced with `MC_EDITION=java|bedrock`. + +--- + +## Player inspector (world-save reads) + +The Players tab keeps a persistent directory of everyone who ever spawned in the world and can open a per-player detail modal with position, health, food, inventory, XP and gamemode. All of it is **read directly from the world save, read-only** — never via console commands: + +- **Java** — `world/playerdata/.dat` (names seeded from `usercache.json`). +- **Bedrock** — the world **LevelDB**. Bedrock never stores gamertags in the DB, so CraftDock builds the identity chain itself: XUID+name from `Player Spawned` log lines (`craftdock_players.json`), then — the first time you open an **online** player — `querytarget` yields the player's `uniqueId` UUID, which keys the DB's `player_` mapping record pointing to the real `player_server_` data record. The binding is persisted (`craftdock_bedrock_ids.json`), so offline reads work forever after. + +While the server is running, reads happen inside a save snapshot (`save hold`/`save query`/`save resume` on Bedrock, `save-all flush` on Java) so files are consistent. + +--- + +## Live map & seed + +The Map tab shows the world seed and embeds [mcseedmap.net](https://mcseedmap.net) pointed at it (a real biome/structure map that allows iframe embedding). The seed is resolved automatically: + +1. `level-seed` from `server.properties` (when the operator set a fixed seed), else +2. **Bedrock** — read from `worlds//level.dat` (the `RandomSeed` NBT long). Bedrock has **no** `seed` console command, so this is the only reliable source. +3. **Java** — the `seed` command over RCON. + +The mcseedmap version segment defaults per edition; override it with `MAP_VERSION` (e.g. `1.21-Java`, `26.30.0-Bedrock`) if the biomes don't match your server version. + +--- + +## Logging + +The panel emits structured JSON logs (one record per line: `ts`, `level`, `component`, `msg`, optional `meta`) to stdout. Level is controlled by `LOG_LEVEL` (`error` < `warn` < `info` < `debug`; default `info`). Command channels (RCON / stdin), seed resolution, HTTP requests, and unhandled errors are all logged — run with `LOG_LEVEL=debug` to see every command sent to the server and its response. + +```bash +docker compose -f docker-compose.bedrock.yaml logs -f # follow panel + server logs +``` + +--- + +## Requirements + +- **Node.js ≥ 18** (dev) or Docker (deploy). +- A Docker host — the panel mounts `/var/run/docker.sock` to control the game container. + +--- + +## Quick start (local development) + +```bash +git clone && cd CraftDock +npm install + +# 1. Generate the admin password hash (prints a raw and a base64 line): +npm run hash -- + +# 2. Create .env from the template and fill it in: +cp .env.example .env +# - ADMIN_PASSWORD_HASH = the raw hash line from step 1 (best for `npm run dev`) +# - SESSION_SECRET = any long random string +# - RCON_PASSWORD = your server's RCON password (leave empty for Bedrock) + +# 3. Run the tests and start the dev server: +npm test +npm run dev # loads .env automatically, hot-reloads on change +``` + +Open and log in with your password. + +> `npm run dev` uses `node --env-file=.env`, so your `.env` is loaded automatically. +> `npm start` (production) does **not** load `.env` — it reads real environment +> variables provided by the container/host. + +--- + +## Running with Docker + +The panel image is built locally from the included `Dockerfile` — it is **not** published to any registry, so a plain `docker compose pull`/`up` without a build step fails with *"pull access denied"*. Always build it: + +Two reference stacks are provided: + +### Java (`docker-compose.java.yaml`) + +```bash +# secrets are read from .env by docker compose (${ADMIN_PASSWORD_HASH}, etc.) +docker compose -f docker-compose.java.yaml up -d --build +docker compose -f docker-compose.java.yaml logs -f +``` + +### Bedrock (`docker-compose.bedrock.yaml`) + +```bash +docker compose -f docker-compose.bedrock.yaml up -d --build +docker compose -f docker-compose.bedrock.yaml logs -f +``` + +Both stacks share the **same** panel env block (only the edition, world folder and +Minecraft image differ) and read their values from `.env` — see [`.env.example`](.env.example) +for the canonical list. + +> **The panel only `expose`s port 3000** (reachable over the Docker network) — +> it is **not** published on the host by default, because the composes target a +> reverse proxy (Coolify/Traefik terminates TLS on 443 and forwards to 3000). +> In Coolify set the service domain **without a port** (`https://your.domain`, +> not `:3000`) and make sure the service's **target port is 3000** — a mismatch +> (e.g. Traefik still forwarding to an old port) makes plain 443 return +> *"no available server"*. +> For **local** testing without a proxy, comment out `expose` and uncomment the +> `ports:` line in the panel service, set `NODE_ENV=development` in `.env` (so +> the secure cookie isn't dropped over plain HTTP), then open +> . + +> **Rebuild after changing panel code:** `--build` is required the first time and +> whenever you change the panel source. A runtime-only change (env var) just needs +> `up -d --force-recreate`. + +> **First Bedrock boot is slow** — `itzg/minecraft-bedrock-server` downloads the +> latest version. Watch the logs until you see the server report it has started. + +--- + +## Deployment prerequisites (Coolify / production) + +The panel controls an existing, **fixed** container — it never recreates it or changes `TYPE`/`VERSION`. For everything to work, the Minecraft service must be configured as follows: + +1. **`stdin_open: true` and `tty: true`** on the Minecraft service. Without a TTY, Docker returns a *multiplexed* log stream (8-byte frame headers) that garbles the Console tab, and stdin (the Bedrock command channel) never reaches the server process. +2. **`OVERRIDE_SERVER_PROPERTIES=false`**, otherwise the `itzg` image regenerates `server.properties` from env vars on every start and the Options tab won't persist. +3. **Java only:** `ENABLE_RCON=true` + a shared `RCON_PASSWORD`. +4. Panel and Minecraft server on the **same Docker network**. +5. The `mc-data` volume **shared** between the panel and the Minecraft server (the panel reads/writes world files directly). +6. Set `ADMIN_PASSWORD_HASH_B64` (the base64 line from `npm run hash`) and a strong `SESSION_SECRET`. Prefer the base64 form here — a raw bcrypt hash's `$` chars get mangled by docker compose / Coolify interpolation. +7. Run the panel with **`NODE_ENV=production`** so the session cookie is marked `secure` — this requires serving the panel over **HTTPS** (Coolify/Traefik terminate TLS on 443). The app already calls `trust proxy`, so the secure cookie works correctly behind the reverse proxy. +8. *(Optional)* Set `MAP_VERSION` if the Map tab's mcseedmap version doesn't match your server. + +--- + +## Environment variables + +| Variable | Required | Default | Description | +|----------|:--------:|---------|-------------| +| `ADMIN_PASSWORD_HASH` | ✅¹ | — | Raw bcrypt hash (`npm run hash -- `). Best for `npm run dev` | +| `ADMIN_PASSWORD_HASH_B64` | ✅¹ | — | Base64 of the bcrypt hash. Best for docker compose / Coolify (no `$` escaping) | +| `SESSION_SECRET` | ✅ | — | Long random string signing the session cookie | +| `MC_CONTAINER_NAME` | | `craftdock-mc-server` | Minecraft container name (matched first) | +| `MC_SERVICE_NAME` | | `minecraft-server` | Compose service name — fallback match when the container name is auto-generated (e.g. Coolify) | +| `MC_DATA_PATH` | | `/minecraft/data` | Path (inside the panel) to the shared MC data volume | +| `MC_EDITION` | | `auto` | `auto` \| `java` \| `bedrock` | +| `MC_WORLD_NAME` | | `world` | World folder under `MC_DATA_PATH` (Bedrock: `worlds`) | +| `RCON_HOST` | | `craftdock-mc-server` | RCON host (Java) | +| `RCON_PORT` | | `25575` | RCON port (Java) | +| `RCON_PASSWORD` | | *(empty)* | RCON password — **required for Java**, leave empty for Bedrock | +| `MAP_VERSION` | | *(per edition)* | mcseedmap version segment override (e.g. `1.21-Java`, `26.30.0-Bedrock`) | +| `PORT` | | `3000` | Port the panel listens on (also the exposed/target port in the composes) | +| `NODE_ENV` | | `development` | `production` marks the session cookie `secure` (needs HTTPS) | +| `LOG_LEVEL` | | `info` | `error` \| `warn` \| `info` \| `debug` | +| `MAX_UPLOAD_MB` | | `1024` | Max world upload size (MB) | + +¹ Provide **one** of `ADMIN_PASSWORD_HASH` or `ADMIN_PASSWORD_HASH_B64`. `npm run hash -- ` prints both; the raw hash wins if both are set. + +--- + +## Security notes + +- The panel mounts the Docker socket, which is **equivalent to root on the host**. Never expose it unauthenticated. Access is gated by a single admin password (bcrypt) with a signed session, enforced on every `/api/*` route **and** the Socket.io handshake. +- Always serve the panel over **HTTPS** in production (secure session cookie). +- Keep `SESSION_SECRET` secret and unique per deployment. + +--- + +## Troubleshooting + +**`pull access denied for craftdock-panel` on `docker compose up`** +The panel image is built locally, not pulled. Add `--build`: `docker compose up -d --build`. + +**`failed to bind host port 0.0.0.0:3000: address already in use`** +Something else is on port 3000 — usually a leftover `npm run dev`. Find and stop it: +```powershell +Get-NetTCPConnection -LocalPort 3000 -State Listen # find the PID +Stop-Process -Id -Force # stop it +``` +Then bring the stack up again. + +**`ERR_CONNECTION_REFUSED` even though the panel logs "listening on :3000"** +The container is listening internally but the host port wasn't published — typically a container that was *created* during a failed port-bind and then only restarted. Recreate it: +```bash +docker compose -f down +docker compose -f up -d --force-recreate +``` +Confirm the host is listening: `Get-NetTCPConnection -LocalPort 3000 -State Listen`. + +**Login succeeds (no error) but the page stays on the login screen** +The session cookie isn't being stored. It's marked `secure` (from `NODE_ENV=production`) while you're on plain `http://localhost` — browsers drop secure cookies over HTTP. For local HTTP testing set `NODE_ENV=development`; in production serve over HTTPS. (Behind a reverse proxy, the app already trusts `X-Forwarded-Proto` via `trust proxy`.) + +**Console tab shows garbled/binary characters** +The Minecraft container is missing `stdin_open: true` + `tty: true`. Add both and recreate it. + +**Options tab changes don't persist after restart** +The Minecraft service is missing `OVERRIDE_SERVER_PROPERTIES=false`. + +**Coolify: `Bind for 0.0.0.0:3000 failed: port is already allocated`** +Don't publish the panel's port on the host under Coolify — its Traefik reaches the container over the network. The composes already ship with `expose: ["3000"]` and the `ports:` line commented out (this is the default); keep it that way and set the service **domain** in Coolify. Keep `ports:` only on the Minecraft service (the raw game port). + +**Coolify: `no available server` when opening `https://your.domain/`** +Traefik has a route for the domain but is forwarding to the wrong container port, so the backend looks empty. Two things to check: (1) the Coolify domain must be set **without a port** (`https://your.domain`, not `:3000`); (2) the service's **target port must be 3000** — the panel listens on 3000. A common trap is an old target port left over from a previous setup (e.g. Traefik still pointing at a port the app no longer listens on): align it to 3000 and redeploy. + +**Coolify / docker compose: login fails, or warning `The "…" variable is not set. Defaulting to a blank string.`** +The `…` is the tail of your bcrypt hash — bcrypt hashes contain `$`, which docker compose interprets as variable interpolation, corrupting the hash. **Use `ADMIN_PASSWORD_HASH_B64`** (the base64 line from `npm run hash`) instead of the raw `ADMIN_PASSWORD_HASH` — base64 has no `$`, so nothing gets mangled. (Alternatively, keep the raw hash but escape every `$` as `$$`.) + +**Status shows `not_found` on Coolify (panel can't see the server)** +Coolify ignores `container_name` and auto-generates names like `minecraft-server--`, so matching by `MC_CONTAINER_NAME` fails. The panel falls back to the compose **service** name — make sure the Minecraft service is named `minecraft-server` (or set `MC_SERVICE_NAME` to match) and that both services are in the **same** Coolify resource (project). The panel also needs the Docker socket mounted (`/var/run/docker.sock`). + +**Map tab shows "waiting for seed" / no map** +The seed is resolved only while the server is **running**. For Bedrock it comes from `worlds//level.dat`, so the `mc-data` volume must be shared with the panel. + +--- + +## Development + +```bash +npm test # run the full Vitest suite +npm run test:watch +npm run dev # start with --env-file=.env and --watch +``` + +### Project layout + +``` +src/ +├── server.js # Express + Socket.io composition +├── main.js # composition root (wires real dependencies) +├── config.js # env var loading + validation +├── logger.js # structured JSON logger +├── healthcheck.js # container healthcheck script +├── appState.js # cached edition/adapter resolution +├── middleware/ # auth (HTTP + socket handshake) +├── services/ # docker, rcon, stdin, properties, world, auth, seed, +│ # playerDirectory, playerDetailService, bedrockIdentityBridge +├── adapters/ # ServerAdapter: java (RCON) / bedrock (stdin) +│ └── playerFile/ # read-only world-save readers (Java NBT / Bedrock LevelDB) +├── routes/ # auth, status, players, properties, world +├── sockets/ # logs stream + status push namespaces +└── public/ # vanilla-JS tabbed SPA (Tailwind via CDN) +``` + +CraftDock also keeps two metadata files at the data root (bundled into world backups): `craftdock_players.json` (persistent XUID→name player directory) and `craftdock_bedrock_ids.json` (Bedrock identity bridge). See [CLAUDE.md](CLAUDE.md) for the full architecture and contribution conventions (numbered feature branches, `#N:` commit prefix). diff --git a/docker-compose.bedrock.yaml b/docker-compose.bedrock.yaml new file mode 100644 index 0000000..6def43a --- /dev/null +++ b/docker-compose.bedrock.yaml @@ -0,0 +1,79 @@ +# CraftDock — Bedrock stack. +# Usage: docker compose -f docker-compose.bedrock.yaml up -d --build +# Secrets/overrides (${...}) are read from the project .env by docker compose. +# See the "Environment variables" table in README.md for every variable. +services: + craftdock-panel: + build: . + container_name: craftdock-panel + # Behind a reverse proxy (Coolify/Traefik) the panel is reached over the + # internal Docker network, so only EXPOSE the port — do NOT publish it on the + # host. In Coolify, set the service domain WITHOUT a port + # (https://your.domain, not https://your.domain:3000) and make sure the + # service's target port is 3000 — otherwise Traefik forwards to the wrong + # port and plain 443 returns "no available server". + # + # For LOCAL testing without a proxy, comment out `expose` and uncomment + # `ports` to publish on the host, then open http://localhost:3000. + expose: + - "3000" + # ports: + # - "${PORT:-3000}:3000" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - mc-data:/minecraft/data + environment: + - NODE_ENV=${NODE_ENV:-production} + - LOG_LEVEL=${LOG_LEVEL:-info} + # base64 of the bcrypt hash — avoids docker/Coolify eating the `$` chars. + - ADMIN_PASSWORD_HASH_B64=${ADMIN_PASSWORD_HASH_B64} + - SESSION_SECRET=${SESSION_SECRET} + - MC_CONTAINER_NAME=craftdock-mc-server + - MC_DATA_PATH=/minecraft/data + - MC_EDITION=bedrock + - MC_WORLD_NAME=worlds + - RCON_HOST=craftdock-mc-server + - RCON_PORT=25575 + - RCON_PASSWORD=${RCON_PASSWORD:-} # Bedrock ignores RCON; leave empty + - MAP_VERSION=${MAP_VERSION:-} + healthcheck: + test: ["CMD", "node", "src/healthcheck.js"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + networks: [craftdock-net] + # `always` (not `unless-stopped`): the panel must come back after ANY exit — + # a crash, its own healthcheck self-restart (kill PID 1), a manual stop, or a + # host/daemon reboot. It's the control plane, so it should never stay down. + restart: always + + minecraft-server: + image: itzg/minecraft-bedrock-server:latest + container_name: craftdock-mc-server + # stdin_open + tty are REQUIRED by CraftDock: without a TTY, stdin (the Bedrock + # command channel via `docker attach`) never reaches the server, and the log + # stream is multiplexed (garbles the Console tab). + stdin_open: true + tty: true + ports: + - "19132:19132/udp" # Bedrock game port (raw UDP — open it in the host firewall) + volumes: + - mc-data:/data + environment: + - EULA=TRUE + - OVERRIDE_SERVER_PROPERTIES=false # so the Options tab persists + healthcheck: + test: ["CMD-SHELL", "/usr/local/bin/mc-health || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 120s + networks: [craftdock-net] + restart: unless-stopped + +volumes: + mc-data: +networks: + craftdock-net: + driver: bridge diff --git a/docker-compose.java.yaml b/docker-compose.java.yaml new file mode 100644 index 0000000..9aca9fa --- /dev/null +++ b/docker-compose.java.yaml @@ -0,0 +1,82 @@ +# CraftDock — Java stack. +# Usage: docker compose -f docker-compose.java.yaml up -d --build +# Secrets/overrides (${...}) are read from the project .env by docker compose. +# See the "Environment variables" table in README.md for every variable. +services: + craftdock-panel: + build: . + container_name: craftdock-panel + # Behind a reverse proxy (Coolify/Traefik) the panel is reached over the + # internal Docker network, so only EXPOSE the port — do NOT publish it on the + # host. In Coolify, set the service domain WITHOUT a port + # (https://your.domain, not https://your.domain:3000) and make sure the + # service's target port is 3000 — otherwise Traefik forwards to the wrong + # port and plain 443 returns "no available server". + # + # For LOCAL testing without a proxy, comment out `expose` and uncomment + # `ports` to publish on the host, then open http://localhost:3000. + expose: + - "3000" + # ports: + # - "${PORT:-3000}:3000" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - mc-data:/minecraft/data + environment: + - NODE_ENV=${NODE_ENV:-production} + - LOG_LEVEL=${LOG_LEVEL:-info} + # base64 of the bcrypt hash — avoids docker/Coolify eating the `$` chars. + - ADMIN_PASSWORD_HASH_B64=${ADMIN_PASSWORD_HASH_B64} + - SESSION_SECRET=${SESSION_SECRET} + - MC_CONTAINER_NAME=craftdock-mc-server + - MC_DATA_PATH=/minecraft/data + - MC_EDITION=java + - MC_WORLD_NAME=world + - RCON_HOST=craftdock-mc-server + - RCON_PORT=25575 + - RCON_PASSWORD=${RCON_PASSWORD:-} + - MAP_VERSION=${MAP_VERSION:-} + healthcheck: + test: ["CMD", "node", "src/healthcheck.js"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + networks: [craftdock-net] + # `always` (not `unless-stopped`): the panel must come back after ANY exit — + # a crash, its own healthcheck self-restart (kill PID 1), a manual stop, or a + # host/daemon reboot. It's the control plane, so it should never stay down. + restart: always + + minecraft-server: + image: itzg/minecraft-server:latest + container_name: craftdock-mc-server + # stdin_open + tty are REQUIRED by CraftDock: without a TTY, Docker returns a + # multiplexed log stream (8-byte frame headers) that garbles the Console tab. + stdin_open: true + tty: true + ports: + - "25565:25565" # Java game port (raw TCP — open it in the host firewall) + volumes: + - mc-data:/data + environment: + - EULA=TRUE + - TYPE=PAPER + - VERSION=LATEST + - ENABLE_RCON=true + - RCON_PASSWORD=${RCON_PASSWORD:-} # required for the panel's Java command channel + - OVERRIDE_SERVER_PROPERTIES=false # so the Options tab persists + healthcheck: + test: ["CMD-SHELL", "/usr/local/bin/mc-health || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 120s + networks: [craftdock-net] + restart: unless-stopped + +volumes: + mc-data: +networks: + craftdock-net: + driver: bridge diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..85b99da --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4899 @@ +{ + "name": "craftdock", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "craftdock", + "version": "0.1.0", + "hasInstallScript": true, + "dependencies": { + "archiver": "^6.0.1", + "bcryptjs": "^2.4.3", + "dockerode": "^4.0.2", + "express": "^4.19.2", + "express-session": "^1.18.0", + "leveldb-zlib": "^1.2.0", + "multer": "^2.0.2", + "prismarine-nbt": "^2.8.0", + "rcon-client": "^4.2.4", + "socket.io": "^4.7.5", + "unzipper": "^0.10.14" + }, + "devDependencies": { + "minecraft-assets": "^1.17.0", + "supertest": "^7.0.0", + "vitest": "^4.1.10" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@balena/dockerignore": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", + "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", + "license": "Apache-2.0" + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.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/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "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/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "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/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "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/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "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": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/ansi/-/ansi-0.3.1.tgz", + "integrity": "sha512-iFY7JCgHbepc0b82yLaw4IMortylNb6wG4kL+4R0C3iv6i+RHGHux/yUX5BTiRvSX/shMnngjR1YyNMnXEFh5A==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/archiver": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-6.0.2.tgz", + "integrity": "sha512-UQ/2nW7NMl1G+1UnrLypQw1VdT9XZg/ECcKPq7l+STzStrSivFIXIp34D8M5zeNGW5NoOupdYCHv6VySCPNNlw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^4.0.1", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^5.0.1" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/archiver-utils": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-4.0.1.tgz", + "integrity": "sha512-Q4Q99idbvzmgCTEAAhi32BkOyq8iVI5EwdO0PmBDSGIzzjYNdcFn7Q7k3OzbLy4kLUPXfJtG6fO2RjftXbobBg==", + "license": "MIT", + "dependencies": { + "glob": "^8.0.0", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/are-we-there-yet": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.0.6.tgz", + "integrity": "sha512-Zfw6bteqM9gQXZ1BIWOgM8xEwMrUGoyL8nW13+O+OOgNX3YhuDN1GDgg1NzdTlmm3j+9sHy7uBZ12r+z9lXnZQ==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.0 || ^1.1.13" + } + }, + "node_modules/are-we-there-yet/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/are-we-there-yet/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/are-we-there-yet/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "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/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "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": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "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==", + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.4.tgz", + "integrity": "sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffer-shims": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz", + "integrity": "sha512-Zy8ZXMyxIT6RMTeY7OP/bDndfj6bwCan7SS98CEndS6deHwWPpseeHlwarNcBim+etXnF9HBc1non5JgDaJU1g==", + "license": "MIT" + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "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/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/camelcase": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", + "integrity": "sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cmake-js": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-6.3.2.tgz", + "integrity": "sha512-7MfiQ/ijzeE2kO+WFB9bv4QP5Dn2yVaAP2acFJr4NIFy2hT4w6O4EpOTLNcohR5IPX7M4wNf/5taIqMj7UA9ug==", + "license": "MIT", + "dependencies": { + "axios": "^0.21.1", + "bluebird": "^3", + "debug": "^4", + "fs-extra": "^5.0.0", + "is-iojs": "^1.0.1", + "lodash": "^4", + "memory-stream": "0", + "npmlog": "^1.2.0", + "rc": "^1.2.7", + "semver": "^5.0.3", + "splitargs": "0", + "tar": "^4", + "unzipper": "^0.8.13", + "url-join": "0", + "which": "^1.0.9", + "yargs": "^3.6.0" + }, + "bin": { + "cmake-js": "bin/cmake-js" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cmake-js/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cmake-js/node_modules/cliui": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", + "integrity": "sha512-0yayqDxWQbqk3ojkYqUKqaAQ6AfNKeKWRNA8kR0WXzAsdHpP4BIaOmMAG87JGuO6qcobyW4GjxHd9PmhEd+T9w==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wrap-ansi": "^2.0.0" + } + }, + "node_modules/cmake-js/node_modules/is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", + "license": "MIT", + "dependencies": { + "number-is-nan": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cmake-js/node_modules/process-nextick-args": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", + "integrity": "sha512-yN0WQmuCX63LP/TMvAg31nvT6m4vDqJEiiv2CAZqWOGNWutc9DfDk1NPYYmKUFmaVM2UwDowH4u5AHWYP/jxKw==", + "license": "MIT" + }, + "node_modules/cmake-js/node_modules/readable-stream": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.5.tgz", + "integrity": "sha512-NkXT2AER7VKXeXtJNSaWLpWIhmtSE3K2PguaLEeWr4JILghcIKqoLt1A3wHrnpDC5+ekf8gfk1GKWkFXe4odMw==", + "license": "MIT", + "dependencies": { + "buffer-shims": "^1.0.0", + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "~1.0.0", + "process-nextick-args": "~1.0.6", + "string_decoder": "~0.10.x", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/cmake-js/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, + "node_modules/cmake-js/node_modules/string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", + "license": "MIT", + "dependencies": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cmake-js/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cmake-js/node_modules/unzipper": { + "version": "0.8.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.8.14.tgz", + "integrity": "sha512-8rFtE7EP5ssOwGpN2dt1Q4njl0N1hUXJ7sSPz0leU2hRdq6+pra57z4YPBlVqm40vcgv6ooKZEAx48fMTv9x4w==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "~1.0.10", + "listenercount": "~1.0.1", + "readable-stream": "~2.1.5", + "setimmediate": "~1.0.4" + } + }, + "node_modules/cmake-js/node_modules/wrap-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha512-vAaEaDM946gbNpH5pLVNR+vX2ht6n0Bt3GXwVB1AuAqZosOvHNF3P7wDnh8KLkSqgUh0uh77le7Owgoz+Z9XBw==", + "license": "MIT", + "dependencies": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cmake-js/node_modules/y18n": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.2.tgz", + "integrity": "sha512-uGZHXkHnhF0XeeAPgnKfPv1bgKAYyVvmNL1xlKsPYZPaIHxGti2hHqvOCQv71XMsLxu1QjergkqogUnms5D3YQ==", + "license": "ISC" + }, + "node_modules/cmake-js/node_modules/yargs": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.32.0.tgz", + "integrity": "sha512-ONJZiimStfZzhKamYvR/xvmgW3uEkAUFSP91y2caTEPhzF6uP2JfPiVZcq66b/YR0C3uitxSV7+T1x8p5bkmMg==", + "license": "MIT", + "dependencies": { + "camelcase": "^2.0.1", + "cliui": "^3.0.3", + "decamelize": "^1.1.1", + "os-locale": "^1.4.0", + "string-width": "^1.0.1", + "window-size": "^0.1.4", + "y18n": "^3.2.0" + } + }, + "node_modules/code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/compress-commons": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-5.0.3.tgz", + "integrity": "sha512-/UIcLWvwAQyVibgpQDPtfNM3SvqN7G9elAPAV7GM0L53EbNWwWiCsWtK8Fwed/APEbptPHXs5PuW+y8Bq8lFTA==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^5.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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": "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/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "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/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-5.0.1.tgz", + "integrity": "sha512-lO1dFui+CEUh/ztYIpgpKItKW9Bb4NWakCRJrnqAbFIYD+OZAwb2VfD5T5eXMw2FNcsDHkQcNl/Wh3iVXYwU6g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "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/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "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/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "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/docker-modem": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz", + "integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.12.tgz", + "integrity": "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==", + "license": "Apache-2.0", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@grpc/grpc-js": "^1.11.1", + "@grpc/proto-loader": "^0.7.13", + "docker-modem": "^5.0.7", + "protobufjs": "^7.3.2", + "tar-fs": "^2.1.4", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "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/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "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/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/engine.io": { + "version": "6.6.9", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", + "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "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": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "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==", + "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/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "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/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/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/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "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": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "license": "MIT", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "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==", + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "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==", + "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-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "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==", + "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/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": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-5.0.0.tgz", + "integrity": "sha512-66Pm4RYbjzdyeuqudYqhFiNBbCIuI9kgRqLPSHIlXHidW8NIQtVdkM1yeZ4lXwuhbTETv3EUGMNHAAw6hiundQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "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/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, + "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/gauge": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-1.2.7.tgz", + "integrity": "sha512-fVbU2wRE91yDvKUnrIaQlHKAWKY5e08PmztCrwuH5YVQ+Z/p3d0ny2T48o6uvAAXHIUnfaQdHkmxYbQft1eHVA==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "ansi": "^0.3.0", + "has-unicode": "^2.0.0", + "lodash.pad": "^4.1.0", + "lodash.padend": "^4.1.0", + "lodash.padstart": "^4.1.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "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/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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==", + "license": "ISC" + }, + "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==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "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/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/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "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/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/invert-kv": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", + "integrity": "sha512-xgs2NH9AE66ucSq4cNG1nhSFghr5l6tdL15Pk+jl46bmmBapgoaY/AacXyaDznAqmGL99TiLSQgO/XazFSKYeQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-iojs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-iojs/-/is-iojs-1.1.0.tgz", + "integrity": "sha512-tLn1j3wYSL6DkvEI+V/j0pKohpa5jk+ER74v6S4SgCXnjS0WA+DoZbwZBrrhgwksMvtuwndyGeG5F8YMsoBzSA==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "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==", + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lcid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", + "integrity": "sha512-YiGkH6EnGrDGqLMITnGjXtGmNtjoXw9SVUzcaos8RBi7Ps0VBylkq+vOcY9QE5poLasPCR849ucFUkl0UzUyOw==", + "license": "MIT", + "dependencies": { + "invert-kv": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/leveldb-zlib": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/leveldb-zlib/-/leveldb-zlib-1.2.0.tgz", + "integrity": "sha512-DQFRg3ScBrCkEa2issB3oEyL1JslPnhIXxXZ/8IsEKlfThlPbDm5ibb//CVVAnNyKOjW1XthEu3N6eQQPadbjg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "bindings": "^1.5.0", + "cmake-js": "^6.1.0", + "debug": "^4.3.1", + "node-addon-api": "^3.1.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "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.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "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/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/lodash.pad": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/lodash.pad/-/lodash.pad-4.5.1.tgz", + "integrity": "sha512-mvUHifnLqM+03YNzeTBS1/Gr6JRFjd3rRx88FHWUvamVaT9k2O/kXha3yBSOwB9/DTQrSTLJNHvLBBt2FdX7Mg==", + "license": "MIT" + }, + "node_modules/lodash.padend": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.padend/-/lodash.padend-4.6.1.tgz", + "integrity": "sha512-sOQs2aqGpbl27tmCS1QNZA09Uqp01ZzWfDUoD+xzTii0E7dSQfRKcRetFwa+uXaxaqL+TKm7CgD2JdKP7aZBSw==", + "license": "MIT" + }, + "node_modules/lodash.padstart": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.padstart/-/lodash.padstart-4.6.1.tgz", + "integrity": "sha512-sW73O6S8+Tg66eY56DBk85aQzzUJDtpoXFBgELMd5P/SotAguo+1kYO6RuYgXxA4HJH3LFTFPASX6ET6bjfriw==", + "license": "MIT" + }, + "node_modules/lodash.reduce": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz", + "integrity": "sha512-6raRe2vxCYBhpBu+B+TtNGUzah+hQjVdu3E17wfusjyrXBka2nBS8OH/gjVZ5PvHOhWmIZTYri09Z6n/QfnNMw==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "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": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memory-stream": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/memory-stream/-/memory-stream-0.0.3.tgz", + "integrity": "sha512-q0D3m846qY6ZkIt+19ZemU5vH56lpOZZwoJc3AICARKh/menBuayQUjAGPrqtHQQMUYERSdOrej92J9kz7LgYA==", + "license": "MMIT", + "dependencies": { + "readable-stream": "~1.0.26-2" + } + }, + "node_modules/memory-stream/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "node_modules/memory-stream/node_modules/readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/memory-stream/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minecraft-assets": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/minecraft-assets/-/minecraft-assets-1.17.0.tgz", + "integrity": "sha512-dBs+8ABdnUp9E19LVg4Y2d6BPxUI02+qLXEEsGhcaB4VHfpIMZXcH6jVWg1pFNtBCOipPdQuIb2dBnXdVZlqfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "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/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "license": "MIT", + "optional": true + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "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/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", + "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npmlog": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-1.2.1.tgz", + "integrity": "sha512-1J5KqSRvESP6XbjPaXt2H6qDzgizLTM7x0y1cXIjP2PpvdCqyNC7TO3cPRKsuYlElbi/DwkzRRdG2zpmE0IktQ==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "ansi": "~0.3.0", + "are-we-there-yet": "~1.0.0", + "gauge": "~1.2.0" + } + }, + "node_modules/number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "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/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.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/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "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/os-locale": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", + "integrity": "sha512-PRT7ZORmwu2MEFt4/fv3Q+mEfN4zetKxufQrkShY2oGvUms9r8otu5HfdyIFHkYXjO7laNsoVGmM2MANfuTA8g==", + "license": "MIT", + "dependencies": { + "lcid": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "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/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/postcss": { + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "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.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prismarine-nbt": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/prismarine-nbt/-/prismarine-nbt-2.8.0.tgz", + "integrity": "sha512-5D6FUZq0PNtf3v/41ImDlwThVesOv5adyqCRMZLzmkUGEmRJNNh5C6AsnvrClBftXs+IF0yqPnZoj8kcNPiMGg==", + "license": "MIT", + "dependencies": { + "protodef": "^1.18.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/protodef": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/protodef/-/protodef-1.19.0.tgz", + "integrity": "sha512-94f3GR7pk4Qi5YVLaLvWBfTGUIzzO8hyo7vFVICQuu5f5nwKtgGDaeC1uXIu49s5to/49QQhEYeL0aigu1jEGA==", + "license": "MIT", + "dependencies": { + "lodash.reduce": "^4.6.0", + "protodef-validator": "^1.3.0", + "readable-stream": "^4.4.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/protodef-validator": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/protodef-validator/-/protodef-validator-1.4.0.tgz", + "integrity": "sha512-2y2coBolqCEuk5Kc3QwO7ThR+/7TZiOit4FrpAgl+vFMvq8w76nDhh09z08e2NQOdrgPLsN2yzXsvRvtADgUZQ==", + "license": "MIT", + "dependencies": { + "ajv": "^6.5.4" + }, + "bin": { + "protodef-validator": "cli.js" + } + }, + "node_modules/protodef/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/protodef/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "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/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=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==", + "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==", + "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/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rcon-client": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/rcon-client/-/rcon-client-4.2.5.tgz", + "integrity": "sha512-AnX1GU/ZTlwtYup3H6h0J1hwfP3OYltXVe+8ReBzmNEepX3xGH8nDg7gYqT5Y9rpAS/LmQ48h0BKINt1YGd8bA==", + "license": "MIT", + "dependencies": { + "typed-emitter": "^0.1.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "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/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/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.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/split-ca": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", + "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", + "license": "ISC" + }, + "node_modules/splitargs": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/splitargs/-/splitargs-0.0.7.tgz", + "integrity": "sha512-UUFYD2oWbNwULH6WoVtLUOw8ch586B+HUqcsAjjjeoBQAM1bD4wZRXu01koaxyd8UeYpybWqW4h+lO1Okv40Tg==", + "license": "ISC" + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "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": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/superagent/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/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/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/tar": { + "version": "7.5.21", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.21.tgz", + "integrity": "sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/tar/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "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": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "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/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-emitter": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-0.1.0.tgz", + "integrity": "sha512-Tfay0l6gJMP5rkil8CzGbLthukn+9BN/VXWcABVFPjOoelJ+koW8BuPZYk+h/L+lEeIp1fSzVRiWRPIjKVjPdg==", + "license": "MIT" + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "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/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.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==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-join": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-0.0.1.tgz", + "integrity": "sha512-H6dnQ/yPAAVzMQRvEvyz01hhfQL5qRWSEt7BX8t9DqnPw9BjMb64fjIRq76Uvf1hkHp+mTZvEVJ5guXOT0Xqaw==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "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": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "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/window-size": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.4.tgz", + "integrity": "sha512-2thx4pB0cV3h+Bw7QmMXcEbdmOzv9t0HFplJH/Lz6yu60hXYy5RT8rUu+wlIreVxWsGN20mo+MHeCSfUpQBwPw==", + "license": "MIT", + "bin": { + "window-size": "cli.js" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "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/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/zip-stream": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-5.0.2.tgz", + "integrity": "sha512-LfOdrUvPB8ZoXtvOBz6DlNClfvi//b5d56mSWyJi7XbH/HfhOHfUhOqxhT/rUiR7yiktlunqRo+jY6y/cWC/5g==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^4.0.1", + "compress-commons": "^5.0.1", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 12.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b805e11 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "craftdock", + "version": "0.1.0", + "type": "module", + "engines": { + "node": ">=18" + }, + "scripts": { + "start": "node src/main.js", + "dev": "node --watch --env-file=.env src/main.js", + "test": "vitest run", + "test:watch": "vitest", + "hash": "node scripts/hash-password.js", + "postinstall": "node scripts/extract-mc-assets.mjs", + "assets": "node scripts/extract-mc-assets.mjs" + }, + "dependencies": { + "archiver": "^6.0.1", + "bcryptjs": "^2.4.3", + "dockerode": "^4.0.2", + "express": "^4.19.2", + "express-session": "^1.18.0", + "leveldb-zlib": "^1.2.0", + "multer": "^2.0.2", + "prismarine-nbt": "^2.8.0", + "rcon-client": "^4.2.4", + "socket.io": "^4.7.5", + "unzipper": "^0.10.14" + }, + "devDependencies": { + "minecraft-assets": "^1.17.0", + "supertest": "^7.0.0", + "vitest": "^4.1.10" + }, + "overrides": { + "axios": "^1.18.0", + "tar": "^7.5.18", + "uuid": "^11.1.1" + } +} diff --git a/scripts/extract-mc-assets.mjs b/scripts/extract-mc-assets.mjs new file mode 100644 index 0000000..ff7e83f --- /dev/null +++ b/scripts/extract-mc-assets.mjs @@ -0,0 +1,59 @@ +// Extracts flat item icons from the minecraft-assets devDependency into +// src/public/assets/mc/items/.png so the panel serves them locally +// (offline-friendly, no runtime dependency, tiny footprint vs the full pkg). +// +// Usage: node scripts/extract-mc-assets.mjs [version] [--required] +// version defaults to the newest available in the package +// --required fail (exit 1) when minecraft-assets is not installed — used by +// the Dockerfile assets stage. Without it the script is a no-op +// warning, so `npm ci --omit=dev` (postinstall) doesn't break. +// +// The output dir is gitignored: icons are generated at Docker build time (see +// Dockerfile "assets" stage) and by postinstall for local dev. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const dataRoot = path.join(here, '..', 'node_modules', 'minecraft-assets', 'minecraft-assets', 'data'); +const outDir = path.join(here, '..', 'src', 'public', 'assets', 'mc', 'items'); + +const required = process.argv.includes('--required'); +const args = process.argv.slice(2).filter((a) => a !== '--required'); + +if (!fs.existsSync(dataRoot)) { + if (required) { + console.error('minecraft-assets is not installed — run `npm install` (with dev deps) first'); + process.exit(1); + } + console.warn('minecraft-assets not installed (prod install?) — skipping icon extraction'); + process.exit(0); +} + +const versions = fs.readdirSync(dataRoot) + .filter((v) => /^\d+\.\d+(\.\d+)?$/.test(v)) + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); +const version = args[0] || versions.at(-1); +const src = path.join(dataRoot, version); +if (!fs.existsSync(src)) { + console.error(`version ${version} not found; available: ${versions.join(', ')}`); + process.exit(1); +} + +const entries = JSON.parse(fs.readFileSync(path.join(src, 'items_textures.json'), 'utf8')); +fs.rmSync(outDir, { recursive: true, force: true }); +fs.mkdirSync(outDir, { recursive: true }); + +let copied = 0, skipped = 0; +for (const { name, texture } of entries) { + if (!name || !texture || texture.includes('missingno')) { skipped++; continue; } + // texture is like "minecraft:item/iron_sword" or "minecraft:block/dirt" + const rel = texture.replace(/^minecraft:/, '').replace(/^item\//, 'items/').replace(/^block\//, 'blocks/'); + const file = path.join(src, `${rel}.png`); + if (!fs.existsSync(file)) { skipped++; continue; } + fs.copyFileSync(file, path.join(outDir, `${name}.png`)); + copied++; +} + +console.log(`minecraft ${version}: ${copied} item icons → ${path.relative(process.cwd(), outDir)} (${skipped} without usable texture)`); diff --git a/scripts/hash-password.js b/scripts/hash-password.js new file mode 100644 index 0000000..6147dec --- /dev/null +++ b/scripts/hash-password.js @@ -0,0 +1,20 @@ +// scripts/hash-password.js +import bcrypt from 'bcryptjs'; + +const password = process.argv[2]; +if (!password) { + console.error('Usage: npm run hash -- '); + process.exit(1); +} +const hash = bcrypt.hashSync(password, 12); +const b64 = Buffer.from(hash, 'utf8').toString('base64'); + +process.stdout.write( + `# Pick ONE. Set it as an environment variable.\n` + + `\n` + + `# Raw hash — use with 'npm run dev' (plain .env, no interpolation):\n` + + `ADMIN_PASSWORD_HASH=${hash}\n` + + `\n` + + `# Base64 hash — use with docker compose / Coolify (no '$' escaping needed):\n` + + `ADMIN_PASSWORD_HASH_B64=${b64}\n`, +); diff --git a/src/adapters/bedrockAdapter.js b/src/adapters/bedrockAdapter.js new file mode 100644 index 0000000..4a8814d --- /dev/null +++ b/src/adapters/bedrockAdapter.js @@ -0,0 +1,92 @@ +import { parsePlayerList, parseWhitelistList, CAPABILITIES, NotSupportedError } from './serverAdapter.js'; + +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Parse the console output of `querytarget` into its first target object, or +// null. Strips color codes, log-line prefixes and the "Target data:" label. +export function parseQueryTarget(res) { + if (!res) return null; + try { + let clean = res.replace(/§[0-9a-fk-or]/ig, '').trim(); + clean = clean.replace(/^\[\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}:\d{3}\s+\w+\]\s*/gm, ''); + if (clean.includes('Target data:')) { + clean = clean.substring(clean.indexOf('Target data:') + 'Target data:'.length); + } + const s = clean.indexOf('['), e = clean.lastIndexOf(']'); + if (s === -1 || e === -1 || e <= s) return null; + const data = JSON.parse(clean.substring(s, e + 1)); + return data?.[0] ?? null; + } catch { + return null; + } +} + +export function createBedrockAdapter(stdinService) { + const send = (cmd) => stdinService.send(cmd); + return { + get capabilities() { return CAPABILITIES.BEDROCK; }, + sendCommand: (raw) => send(raw.startsWith('/') ? raw.slice(1) : raw), + async listPlayers() { return parsePlayerList(await send('list')); }, + whitelistAdd: (n) => send(`allowlist add "${n}"`), + whitelistRemove: (n) => send(`allowlist remove "${n}"`), + whitelistOn: () => send('allowlist on'), + whitelistOff: () => send('allowlist off'), + async whitelistList() { return parseWhitelistList(await send('allowlist list')); }, + async ban() { throw new NotSupportedError('ban'); }, + async pardon() { throw new NotSupportedError('pardon'); }, + op: (n) => send(`op "${n}"`), + deop: (n) => send(`deop "${n}"`), + kick: (n, reason = '') => send(`kick "${n}" ${reason}`.trim()), + give: (n, item, count = 1) => send(`give "${n}" ${item} ${count}`), + gamemode: (n, mode) => send(`gamemode ${mode} "${n}"`), + teleport: (n, target) => send(`tp "${n}" ${target}`), + // Snapshot protocol for safely reading the LevelDB while the server runs: + // `save hold` → poll `save query` until the files are flushed and ready → + // (caller reads the files) → `saveResume`. Returns true when ready. + async saveHold() { + try { + await send('save hold'); + for (let i = 0; i < 12; i++) { + const out = await send('save query'); + if (/ready to be copied|Data saved/i.test(out)) return true; + await delay(250); + } + } catch { + // Command stream issue or timeout — snapshot hold failed + } + return false; + }, + saveResume: () => send('save resume').catch(() => {}), + + // Returns the live entity's uniqueId (the player's MsaId/SelfSignedId UUID, + // which keys the LevelDB player_ mapping record), used to bridge a + // gamertag to its LevelDB record. Online players only. + async queryUniqueId(n) { + try { + const res = await send(`querytarget @a[name="${n}"]`); + const target = parseQueryTarget(res); + if (target?.uniqueId != null) return String(target.uniqueId); + // Fallback: regex over raw output when the JSON block is mangled + const match = res?.match(/"uniqueId"\s*:\s*(-?\d+|"[^"]+")/i); + return match ? match[1].replace(/"/g, '') : null; + } catch { + return null; + } + }, + + // NOTE: Bedrock has no `seed` console command (Java-only). The seed is read + // from worlds//level.dat by seedService — do NOT add getSeed here. + async getPlayerPosition(n) { + try { + const target = parseQueryTarget(await send(`querytarget @a[name="${n}"]`)); + if (!target?.position) return null; + const { x, y, z } = target.position; + const dimension = target.dimension === 1 ? 'nether' : target.dimension === 2 ? 'end' : 'overworld'; + return { x, y, z, dimension }; + } catch { + return null; + } + } + }; +} + diff --git a/src/adapters/index.js b/src/adapters/index.js new file mode 100644 index 0000000..68f2e3c --- /dev/null +++ b/src/adapters/index.js @@ -0,0 +1,12 @@ +import { createJavaAdapter } from './javaAdapter.js'; +import { createBedrockAdapter } from './bedrockAdapter.js'; + +export function isBedrock(type) { + return typeof type === 'string' && type.toUpperCase() === 'BEDROCK'; +} + +export function createAdapter(type, { rconService, stdinService }) { + return isBedrock(type) + ? createBedrockAdapter(stdinService) + : createJavaAdapter(rconService); +} diff --git a/src/adapters/javaAdapter.js b/src/adapters/javaAdapter.js new file mode 100644 index 0000000..b33fc6d --- /dev/null +++ b/src/adapters/javaAdapter.js @@ -0,0 +1,58 @@ +import { parsePlayerList, parseWhitelistList, CAPABILITIES } from './serverAdapter.js'; + +export function createJavaAdapter(rconService) { + const send = (cmd) => rconService.send(cmd); + return { + get capabilities() { return CAPABILITIES.JAVA; }, + sendCommand: (raw) => send(raw.startsWith('/') ? raw.slice(1) : raw), + async listPlayers() { return parsePlayerList(await send('list')); }, + whitelistAdd: (n) => send(`whitelist add ${n}`), + whitelistRemove: (n) => send(`whitelist remove ${n}`), + whitelistOn: () => send('whitelist on'), + whitelistOff: () => send('whitelist off'), + async whitelistList() { return parseWhitelistList(await send('whitelist list')); }, + ban: (n, reason = '') => send(`ban ${n} ${reason}`.trim()), + pardon: (n) => send(`pardon ${n}`), + op: (n) => send(`op ${n}`), + deop: (n) => send(`deop ${n}`), + kick: (n, reason = '') => send(`kick ${n} ${reason}`.trim()), + give: (n, item, count = 1) => send(`give ${n} ${item} ${count}`), + gamemode: (n, mode) => send(`gamemode ${mode} ${n}`), + teleport: (n, target) => send(`tp ${n} ${target}`), + // Flush all worlds to disk so a subsequent playerdata read is fresh. Java's + // save is atomic per file, so no hold/resume dance is needed. + forceSave: () => send('save-all flush'), + async getSeed() { + const res = await send('seed'); + const clean = res.replace(/§[0-9a-fk-or]/ig, ''); + const m = clean.match(/Seed:\s*\[?(-?\d+)\]?/i); + return m ? m[1] : null; + }, + async getPlayerPosition(n) { + try { + const res = await send(`tp ${n} ~ ~ ~`); + const clean = res.replace(/§[0-9a-fk-or]/ig, ''); + const m = clean.match(/to\s+(-?\d+(?:\.\d+)?)(?:,\s*|\s+)(-?\d+(?:\.\d+)?)(?:,\s*|\s+)(-?\d+(?:\.\d+)?)/i); + if (!m) return null; + + let dimension = 'overworld'; + try { + const resDim = await send(`data get entity ${n} Dimension`); + const cleanDim = resDim.replace(/§[0-9a-fk-or]/ig, ''); + if (cleanDim.includes('the_nether')) dimension = 'nether'; + else if (cleanDim.includes('the_end')) dimension = 'end'; + } catch { /* ignore dimension if command fails */ } + + return { + x: parseFloat(m[1]), + y: parseFloat(m[2]), + z: parseFloat(m[3]), + dimension + }; + } catch { + return null; + } + } + }; +} + diff --git a/src/adapters/playerFile/bedrockPlayerFile.js b/src/adapters/playerFile/bedrockPlayerFile.js new file mode 100644 index 0000000..4877363 --- /dev/null +++ b/src/adapters/playerFile/bedrockPlayerFile.js @@ -0,0 +1,179 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import pkg from 'leveldb-zlib'; +import nbt from 'prismarine-nbt'; +import { normalizeBedrock } from './nbtPlayer.js'; + +const { LevelDB, Iterator } = pkg; + +const SERVER_PREFIX = 'player_server_'; +const PLAYER_PREFIX = 'player_'; + +// Helper for matching uniqueId whether given as a decimal integer string or UUID format string +export function matchUniqueId(a, b) { + if (a === b) return true; + if (!a || !b) return false; + const cleanA = String(a).replace(/-/g, '').toLowerCase(); + const cleanB = String(b).replace(/-/g, '').toLowerCase(); + if (cleanA === cleanB) return true; + + // Bedrock querytarget uniqueId can be a 128-bit UUID string or 64-bit int string / decimal + // Compare low 64 bits or numeric BigInt representations if compatible + try { + const toBigInt = (str) => { + const c = str.replace(/-/g, ''); + if (/^[0-9a-f]{32}$/i.test(c)) { + // Take lower 64 bits of hex UUID + const lowHex = c.slice(16); + const big = BigInt('0x' + lowHex); + const shift = 1n << 63n; + const mask = (1n << 64n) - 1n; + const val = big & mask; + return val >= shift ? val - (1n << 64n) : val; + } + if (/^-?\d+$/.test(str)) { + return BigInt(str); + } + return null; + }; + const bigA = toBigInt(String(a)); + const bigB = toBigInt(String(b)); + if (bigA !== null && bigB !== null && bigA === bigB) return true; + } catch { /* ignore parse error */ } + + return false; +} + +// Parse one Bedrock player NBT value (little-endian) into PlayerData. +export async function parsePlayerBuffer(buf) { + const { parsed } = await nbt.parse(Buffer.from(buf), 'little'); + return normalizeBedrock(nbt.simplify(parsed)); +} + +// Locate the world's LevelDB directory. Bedrock stores it at +// ///db (CraftDock uses MC_WORLD_NAME=worlds +// with the actual level folder inside). Falls back to //db. +export function resolveDbPath(config) { + const base = path.join(config.mcDataPath, config.mcWorldName || 'worlds'); + try { + for (const entry of fs.readdirSync(base, { withFileTypes: true })) { + if (entry.isDirectory() && fs.existsSync(path.join(base, entry.name, 'db'))) { + return path.join(base, entry.name, 'db'); + } + } + } catch { /* base missing */ } + const direct = path.join(base, 'db'); + return fs.existsSync(direct) ? direct : null; +} + +export function createBedrockPlayerFile(config) { + const latin1 = (b) => Buffer.from(b).toString('latin1'); + + // Open read-only, run fn, always close. Ultra-light: one short-lived handle + // per read. Concurrency with the running server is handled upstream by the + // detail service issuing `save hold` before reading. + async function withDb(fn) { + const dbPath = resolveDbPath(config); + if (!dbPath) return null; + let db = null; + try { + db = new LevelDB(dbPath, { createIfMissing: false }); + let openTimer; + const openPromise = db.open(); + const timeoutPromise = new Promise((_, reject) => { + openTimer = setTimeout(() => reject(new Error('LevelDB open timeout')), 2000); + }); + await Promise.race([openPromise, timeoutPromise]).finally(() => clearTimeout(openTimer)); + return await fn(db); + } catch { + return null; + } finally { + if (db) { + try { await db.close(); } catch {} + } + } + } + + + async function readPlayer(uuid) { + const data = await withDb(async (db) => { + const val = await db.get(Buffer.from(SERVER_PREFIX + uuid, 'latin1')); + return val ? parsePlayerBuffer(val) : null; + }); + if (!data) return null; + try { + data.savedAt = fs.statSync(resolveDbPath(config)).mtime.toISOString(); + } catch { data.savedAt = null; } + return data; + } + + // The DB stores identity mapping records keyed player_ (uuid = MsaId or + // SelfSignedId — querytarget's `uniqueId` is one of these) whose NBT holds + // ServerId = "player_server_". Direct lookup, no scan. + async function resolveViaMapping(db, uniqueId) { + if (!/^[0-9a-f-]{36}$/i.test(String(uniqueId))) return null; + const val = await db.get(Buffer.from(PLAYER_PREFIX + String(uniqueId).toLowerCase(), 'latin1')).catch(() => null); + if (!val) return null; + try { + const { parsed } = await nbt.parse(Buffer.from(val), 'little'); + const serverId = nbt.simplify(parsed)?.ServerId; + if (typeof serverId === 'string' && serverId.startsWith(SERVER_PREFIX)) { + return serverId.slice(SERVER_PREFIX.length); + } + } catch { /* not a mapping record */ } + return null; + } + + // Resolve a live uniqueId to its player_server_ record: first via the + // player_ mapping record, then by scanning player_server_* entries for + // a matching NBT UniqueID. NOTE the leveldb-zlib iterator yields [value, key]. + async function findByUniqueId(uniqueId) { + const targetStr = String(uniqueId); + return withDb(async (db) => { + const mapped = await resolveViaMapping(db, targetStr); + if (mapped) { + const val = await db.get(Buffer.from(SERVER_PREFIX + mapped, 'latin1')).catch(() => null); + return { uuid: mapped, data: val ? await parsePlayerBuffer(val) : null }; + } + const iter = new Iterator(db, { keys: true, values: true }); + try { + let e; + while ((e = await iter.next()) != null) { + const key = latin1(e[1]); + if (!key.startsWith(SERVER_PREFIX)) continue; + const data = await parsePlayerBuffer(e[0]); + if (!data?.uniqueId) continue; + + if ( + data.uniqueId === targetStr || + matchUniqueId(data.uniqueId, targetStr) + ) { + return { uuid: key.slice(SERVER_PREFIX.length), data }; + } + } + return null; + } finally { + await iter.end(); + } + }); + } + + async function listServerUuids() { + return withDb(async (db) => { + const iter = new Iterator(db, { keys: true, values: true }); + const out = []; + try { + let e; + while ((e = await iter.next()) != null) { + const key = latin1(e[1]); // tuple is [value, key] + if (key.startsWith(SERVER_PREFIX)) out.push(key.slice(SERVER_PREFIX.length)); + } + return out; + } finally { + await iter.end(); + } + }) || []; + } + + return { readPlayer, findByUniqueId, listServerUuids }; +} diff --git a/src/adapters/playerFile/index.js b/src/adapters/playerFile/index.js new file mode 100644 index 0000000..16e40bf --- /dev/null +++ b/src/adapters/playerFile/index.js @@ -0,0 +1,11 @@ +import { createJavaPlayerFile } from './javaPlayerFile.js'; +import { createBedrockPlayerFile } from './bedrockPlayerFile.js'; + +// Returns the read-only player-file adapter for the given edition. Both expose +// `readPlayer(id)`; the Bedrock adapter adds `findByUniqueId`/`listServerUuids` +// used by the identity bridge. +export function createPlayerFileAdapter(edition, config) { + return edition === 'bedrock' + ? createBedrockPlayerFile(config) + : createJavaPlayerFile(config); +} diff --git a/src/adapters/playerFile/javaPlayerFile.js b/src/adapters/playerFile/javaPlayerFile.js new file mode 100644 index 0000000..d21143a --- /dev/null +++ b/src/adapters/playerFile/javaPlayerFile.js @@ -0,0 +1,25 @@ +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import nbt from 'prismarine-nbt'; +import { normalizeJava } from './nbtPlayer.js'; + +// Reads Java per-player save data from /playerdata/.dat (gzip'd +// big-endian NBT). `id` is the player UUID (from usercache.json). +export function createJavaPlayerFile(config) { + const playerdataDir = path.join(config.mcDataPath, config.mcWorldName || 'world', 'playerdata'); + + async function readPlayer(uuid) { + const file = path.join(playerdataDir, `${uuid}.dat`); + let buf, stat; + try { + buf = await fsp.readFile(file); + stat = await fsp.stat(file); + } catch { + return null; // no save yet for this player + } + const { parsed } = await nbt.parse(buf); // auto-detects gzip + endianness + return { ...normalizeJava(nbt.simplify(parsed)), savedAt: stat.mtime.toISOString() }; + } + + return { readPlayer }; +} diff --git a/src/adapters/playerFile/nbtPlayer.js b/src/adapters/playerFile/nbtPlayer.js new file mode 100644 index 0000000..296fa93 --- /dev/null +++ b/src/adapters/playerFile/nbtPlayer.js @@ -0,0 +1,104 @@ +// Normalizes decoded player NBT (post `nbt.simplify`) into a stable, edition- +// agnostic PlayerData shape consumed by the detail service and the UI. +// +// PlayerData = { +// position: { x, y, z } | null, +// dimension: 'overworld' | 'nether' | 'end', +// health: { current, max } | null, +// food: number | null, +// inventory: [ { slot, name, count } ], // slots 0-8 hotbar, 9-35 main grid +// armor: { head, chest, legs, feet }, // each { name, count } | null +// offhand: { name, count } | null, +// gamemode: string | null, +// xp: { level } | null, +// uniqueId: string | null, // Bedrock only — used by the identity bridge +// } + +// prismarine-nbt represents a `long` as a [high32, low32] signed-int pair. +// Combine to a decimal string (matches Bedrock's querytarget uniqueId format). +export function int64ToString(v) { + if (typeof v === 'bigint') return v.toString(); + if (typeof v === 'number') return String(v); + if (Array.isArray(v) && v.length === 2) { + const [hi, lo] = v; + return (BigInt(hi) * (1n << 32n) + BigInt(lo >>> 0)).toString(); + } + return v == null ? null : String(v); +} + +function bedrockDimension(code) { + return code === 1 ? 'nether' : code === 2 ? 'end' : 'overworld'; +} + +function javaDimension(dim) { + const s = String(dim); + if (s.includes('nether') || dim === -1) return 'nether'; + if (s.includes('end') || dim === 1) return 'end'; + return 'overworld'; +} + +const BEDROCK_GAMEMODE = { 0: 'survival', 1: 'creative', 2: 'adventure', 5: 'default', 6: 'spectator' }; +const JAVA_GAMEMODE = { 0: 'survival', 1: 'creative', 2: 'adventure', 3: 'spectator' }; + +function pos(arr) { + return Array.isArray(arr) && arr.length === 3 + ? { x: arr[0], y: arr[1], z: arr[2] } + : null; +} + +const item = (name, count) => (name ? { name, count } : null); + +export function normalizeBedrock(s = {}) { + const attr = (name) => (s.Attributes || []).find((a) => a.Name === name); + const hp = attr('minecraft:health'); + const hunger = attr('minecraft:player.hunger'); + return { + position: pos(s.Pos), + dimension: bedrockDimension(s.DimensionId), + health: hp ? { current: hp.Current, max: hp.Max } : null, + food: hunger ? hunger.Current : null, + inventory: (s.Inventory || []) + .filter((i) => i && i.Name) + .map((i) => ({ slot: i.Slot, name: i.Name, count: i.Count })), + // Bedrock keeps armor/offhand in their own lists: Armor = [head, chest, legs, feet(, body)] + armor: { + head: item(s.Armor?.[0]?.Name, s.Armor?.[0]?.Count), + chest: item(s.Armor?.[1]?.Name, s.Armor?.[1]?.Count), + legs: item(s.Armor?.[2]?.Name, s.Armor?.[2]?.Count), + feet: item(s.Armor?.[3]?.Name, s.Armor?.[3]?.Count), + }, + offhand: item(s.Offhand?.[0]?.Name, s.Offhand?.[0]?.Count), + gamemode: BEDROCK_GAMEMODE[s.PlayerGameMode] ?? null, + xp: typeof s.PlayerLevel === 'number' ? { level: s.PlayerLevel } : null, + uniqueId: 'UniqueID' in s ? int64ToString(s.UniqueID) : null, + }; +} + +// Java stores everything in one Inventory list: 0-8 hotbar, 9-35 main grid, +// 100=feet 101=legs 102=chest 103=head, -106=offhand. +const JAVA_ARMOR_SLOT = { 100: 'feet', 101: 'legs', 102: 'chest', 103: 'head' }; + +export function normalizeJava(s = {}) { + const all = (s.Inventory || []).filter((i) => i && i.id); + const armor = { head: null, chest: null, legs: null, feet: null }; + let offhand = null; + const inventory = []; + for (const i of all) { + const entry = { slot: i.Slot, name: i.id, count: i.Count }; + if (JAVA_ARMOR_SLOT[i.Slot]) armor[JAVA_ARMOR_SLOT[i.Slot]] = item(i.id, i.Count); + else if (i.Slot === -106) offhand = item(i.id, i.Count); + else inventory.push(entry); + } + return { + position: pos(s.Pos), + dimension: javaDimension(s.Dimension), + health: typeof s.Health === 'number' ? { current: s.Health, max: 20 } : null, + food: typeof s.foodLevel === 'number' ? s.foodLevel : null, + inventory, + armor, + offhand, + gamemode: JAVA_GAMEMODE[s.playerGameType] ?? null, + xp: typeof s.XpLevel === 'number' ? { level: s.XpLevel } : null, + uniqueId: null, + }; +} diff --git a/src/adapters/serverAdapter.js b/src/adapters/serverAdapter.js new file mode 100644 index 0000000..532384b --- /dev/null +++ b/src/adapters/serverAdapter.js @@ -0,0 +1,62 @@ +export class NotSupportedError extends Error { + constructor(action) { + super(`Action not supported on this edition: ${action}`); + this.name = 'NotSupportedError'; + } +} + +const COMMON = ['listPlayers', 'whitelistAdd', 'whitelistRemove', 'whitelistOn', 'whitelistOff', 'whitelistList', 'op', 'deop', 'kick', 'give', 'gamemode', 'teleport', 'sendCommand']; + +export const CAPABILITIES = { + JAVA: new Set([...COMMON, 'ban', 'pardon']), + BEDROCK: new Set([...COMMON]), +}; + +export const PLAYER_NAME_REGEX = /^[a-zA-Z0-9_ ]{3,16}$/; + +/** + * Parses the output of `whitelist list` / `allowlist list`. + * Typical formats: + * Java: "There are 2 whitelisted players: steve, alex" + * Bedrock: "There are 2 allowlisted players: steve, alex" + */ +export function parseWhitelistList(text) { + // Bedrock returns a structured JSON block, e.g. + // ###* {"command":"allowlist","result":[{"name":"steve"}]} *### + // When a `result` field is present it is AUTHORITATIVE: a null/empty result + // means the list is empty. We must never fall through to text parsing here — + // doing so scrapes ghost "names" out of console log lines (the bug that + // produced `allowlist remove 53:21:185 INFO] ###* {`). + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (jsonMatch) { + try { + const data = JSON.parse(jsonMatch[0]); + if (data && 'result' in data) { + return Array.isArray(data.result) ? data.result.map((p) => p.name).filter((s) => s && PLAYER_NAME_REGEX.test(s)) : []; + } + } catch { /* not the JSON we expected — fall through to Java text form */ } + } + + // Java text form only: "There are N whitelisted players: a, b". Matched + // strictly so unrelated log lines (timestamps, errors) never yield names. + const m = text.replace(/\s+/g, ' ').match(/There are \d+ \w+ players:\s*(.+)$/i); + if (m && m[1].trim()) return m[1].split(',').map((s) => s.trim()).filter((s) => PLAYER_NAME_REGEX.test(s)); + return []; +} + +export function parsePlayerList(text) { + const clean = text.replace(/\s+/g, ' ').trim(); + // Java: "There are 2 of a max of 20 players online: a, b" + let m = clean.match(/There are (\d+) of a max of (\d+) players online:?\s*(.*)$/i); + // Bedrock: "There are 2/20 players online: a, b" + if (!m) m = clean.match(/There are (\d+)\/(\d+) players online:?\s*(.*)$/i); + if (!m) return { online: 0, max: 0, players: [] }; + const online = Number(m[1]); + const max = Number(m[2]); + if (online === 0) return { online: 0, max, players: [] }; + const players = (m[3] || '') + .split(',') + .map((s) => s.trim()) + .filter((s) => PLAYER_NAME_REGEX.test(s)); + return { online, max, players }; +} diff --git a/src/appState.js b/src/appState.js new file mode 100644 index 0000000..59171aa --- /dev/null +++ b/src/appState.js @@ -0,0 +1,87 @@ +import { createAdapter, isBedrock } from './adapters/index.js'; + +export function createAppState({ config, dockerService, rconService, stdinService }) { + let cached = null; + + // Returns { type, definitive }. `definitive` is false when we could not read a + // real TYPE (e.g. container stopped) and had to fall back — such results must + // not be cached, or the edition can get stuck wrong until restart. + async function resolveType() { + if (config.mcEdition === 'bedrock') return { type: 'BEDROCK', definitive: true }; + if (config.mcEdition === 'java') return { type: 'PAPER', definitive: true }; + const info = await dockerService.inspect(); + return info.type + ? { type: info.type, definitive: true } + : { type: 'PAPER', definitive: false }; + } + + function build(type) { + const adapter = createAdapter(type, { rconService, stdinService }); + adapter._edition = isBedrock(type) ? 'bedrock' : 'java'; + + let listPlayersCache = null; + let listPlayersTime = 0; + let whitelistCache = null; + let whitelistTime = 0; + + const originalListPlayers = adapter.listPlayers; + adapter.listPlayers = async function() { + const now = Date.now(); + if (listPlayersCache && (now - listPlayersTime < 10000)) { + return listPlayersCache; + } + listPlayersCache = await originalListPlayers.call(adapter); + listPlayersTime = now; + return listPlayersCache; + }; + + const originalWhitelistList = adapter.whitelistList; + adapter.whitelistList = async function() { + const now = Date.now(); + if (whitelistCache && (now - whitelistTime < 30000)) { + return whitelistCache; + } + whitelistCache = await originalWhitelistList.call(adapter); + whitelistTime = now; + return whitelistCache; + }; + + adapter.clearCaches = () => { + listPlayersCache = null; + listPlayersTime = 0; + whitelistCache = null; + whitelistTime = 0; + }; + + const wrapClear = (methodName) => { + if (typeof adapter[methodName] === 'function') { + const original = adapter[methodName]; + adapter[methodName] = async function(...args) { + const res = await original.apply(adapter, args); + adapter.clearCaches(); + return res; + }; + } + }; + + wrapClear('whitelistAdd'); + wrapClear('whitelistRemove'); + wrapClear('whitelistOn'); + wrapClear('whitelistOff'); + + return adapter; + } + + return { + async getAdapter() { + if (cached) return cached; + const { type, definitive } = await resolveType(); + const adapter = build(type); + if (definitive) cached = adapter; + return adapter; + }, + async getEdition() { + return (await this.getAdapter())._edition; + }, + }; +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..c9ceab1 --- /dev/null +++ b/src/config.js @@ -0,0 +1,46 @@ +// SESSION_SECRET is always required. RCON_PASSWORD is Java-only (optional). The +// admin hash may come from ADMIN_PASSWORD_HASH (raw) or ADMIN_PASSWORD_HASH_B64 +// (base64 of the same hash — avoids `$` interpolation issues under docker/Coolify). +const REQUIRED = ['SESSION_SECRET']; + +// bcrypt hashes contain `$`, which docker compose / Coolify interpret as variable +// interpolation. Providing the hash base64-encoded sidesteps that entirely. +function resolveAdminHash(env) { + const raw = (env.ADMIN_PASSWORD_HASH || '').trim(); + if (raw) return raw; + const b64 = (env.ADMIN_PASSWORD_HASH_B64 || '').trim(); + if (b64) return Buffer.from(b64, 'base64').toString('utf8').trim(); + return ''; +} + +export function loadConfig(env = process.env) { + const missing = REQUIRED.filter((k) => !env[k]); + const adminPasswordHash = resolveAdminHash(env); + if (!adminPasswordHash) missing.push('ADMIN_PASSWORD_HASH (or ADMIN_PASSWORD_HASH_B64)'); + if (missing.length) { + throw new Error(`Missing required env vars: ${missing.join(', ')}`); + } + const num = (v, d) => { + if (v == null || v === '') return d; + const n = Number(v); + return Number.isNaN(n) ? d : n; + }; + return { + adminPasswordHash, + sessionSecret: env.SESSION_SECRET, + mcContainerName: env.MC_CONTAINER_NAME || 'craftdock-mc-server', + // Compose service name of the MC container, used as a fallback when the + // container name is auto-generated (e.g. Coolify ignores container_name). + mcServiceName: env.MC_SERVICE_NAME || 'minecraft-server', + mcDataPath: env.MC_DATA_PATH || '/minecraft/data', + mcEdition: env.MC_EDITION || 'auto', + mcWorldName: env.MC_WORLD_NAME || 'world', + rconHost: env.RCON_HOST || 'craftdock-mc-server', + rconPort: num(env.RCON_PORT, 25575), + rconPassword: env.RCON_PASSWORD || '', + mapVersion: env.MAP_VERSION || '', + port: num(env.PORT, 3000), + nodeEnv: env.NODE_ENV || 'development', + maxUploadBytes: num(env.MAX_UPLOAD_MB, 1024) * 1024 * 1024, + }; +} diff --git a/src/healthcheck.js b/src/healthcheck.js new file mode 100644 index 0000000..1638e65 --- /dev/null +++ b/src/healthcheck.js @@ -0,0 +1,41 @@ +import fs from 'node:fs'; + +const failFile = '/tmp/health_failures'; + +async function check() { + try { + // Abort before Docker's own `timeout` (5s) SIGKILLs this process. If the app + // hangs (event loop blocked), an untimed fetch stays pending until Docker kills + // us — the catch below never runs, the failure counter never advances, and the + // self-restart (kill PID 1) never fires. A 4s AbortSignal guarantees the catch + // runs first so the counter advances and the container can heal itself. + const port = process.env.PORT || 3000; + const res = await fetch(`http://127.0.0.1:${port}/api/health`, { + signal: AbortSignal.timeout(4000), + }); + if (res.ok) { + try { fs.unlinkSync(failFile); } catch {} + process.exit(0); + } else { + throw new Error('Not OK'); + } + } catch (err) { + let fails = 0; + try { + fails = parseInt(fs.readFileSync(failFile, 'utf8')) || 0; + } catch {} + fails++; + + if (fails >= 3) { + try { fs.unlinkSync(failFile); } catch {} + // Kill PID 1 to force compose restart + process.kill(1, 'SIGKILL'); + process.exit(1); + } + + fs.writeFileSync(failFile, String(fails)); + process.exit(1); + } +} + +check(); diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..6bef206 --- /dev/null +++ b/src/logger.js @@ -0,0 +1,77 @@ +// Lightweight structured logger. No dependencies — writes one record per line +// to a stream (stdout by default). Level is controlled by LOG_LEVEL +// (error < warn < info < debug); anything above the threshold is dropped. +// Format is controlled by LOG_FORMAT (json | text, default: text). + +const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 }; + +function serializeMeta(meta) { + if (meta == null) return undefined; + if (meta instanceof Error) { + return { error: meta.message, stack: meta.stack, cause: meta.cause }; + } + if (typeof meta !== 'object') { + return { value: meta }; + } + const out = {}; + for (const [key, value] of Object.entries(meta)) { + if (value instanceof Error) { + out[key] = { error: value.message, stack: value.stack, cause: value.cause }; + } else { + out[key] = value; + } + } + return out; +} + +function formatTextRecord(record) { + const lvlStr = record.level.toUpperCase().padEnd(5); + const metaStr = record.meta ? ` ${JSON.stringify(record.meta)}` : ''; + return `[${record.ts}] [${lvlStr}] [${record.component}] ${record.msg}${metaStr}\n`; +} + +export function createLogger(component, { + level = process.env.LOG_LEVEL || 'info', + format = process.env.LOG_FORMAT || 'text', + stream = process.stdout, +} = {}) { + const threshold = LEVELS[level] ?? LEVELS.info; + const isJson = format === 'json'; + + function log(lvl, msg, meta) { + if (LEVELS[lvl] > threshold) return; + const record = { + ts: new Date().toISOString(), + level: lvl, + component, + msg, + }; + const m = serializeMeta(meta); + if (m !== undefined) record.meta = m; + + if (isJson) { + stream.write(JSON.stringify(record) + '\n'); + } else { + stream.write(formatTextRecord(record)); + } + } + + return { + error: (msg, meta) => log('error', msg, meta), + warn: (msg, meta) => log('warn', msg, meta), + info: (msg, meta) => log('info', msg, meta), + debug: (msg, meta) => log('debug', msg, meta), + time: (label, lvl = 'info') => { + const start = Date.now(); + return (meta = {}) => { + const durationMs = Date.now() - start; + log(lvl, `${label} took ${durationMs}ms`, { ...meta, durationMs }); + }; + }, + child: (sub) => createLogger(`${component}:${sub}`, { level, format, stream }), + }; +} + +// Default application logger. +export const logger = createLogger('craftdock'); + diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..49c64ae --- /dev/null +++ b/src/main.js @@ -0,0 +1,93 @@ +import Docker from 'dockerode'; +import archiver from 'archiver'; +import unzipper from 'unzipper'; +import multer from 'multer'; +import os from 'node:os'; +import { createReadStream } from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; + +import { loadConfig } from './config.js'; +import { logger } from './logger.js'; +import { createDockerService } from './services/dockerService.js'; +import { createRconService } from './services/rconService.js'; +import { createStdinService } from './services/stdinService.js'; +import { createPropertiesService } from './services/propertiesService.js'; +import { createWorldService } from './services/worldService.js'; +import { createAuthService } from './services/authService.js'; +import { createSeedService } from './services/seedService.js'; +import { ensureFirstBootDefaults } from './services/firstBootService.js'; +import { createAppState } from './appState.js'; +import { startServer } from './server.js'; + +const config = loadConfig(); + +process.on('uncaughtException', (err) => { + logger.error('uncaught exception', { error: err.message, stack: err.stack, cause: err.cause }); +}); + +process.on('unhandledRejection', (reason) => { + logger.error('unhandled promise rejection', { + error: reason instanceof Error ? reason.message : String(reason), + stack: reason instanceof Error ? reason.stack : undefined, + }); +}); + +// Ensure shared configuration files have correct permissions (writable by anyone) +// so the Minecraft container (running as user minecraft UID 1000) does not hit write permission errors. +const FILES_TO_CHMOD = [ + 'allowlist.json', 'permissions.json', + 'whitelist.json', 'ops.json', 'usercache.json', + 'banned-players.json', 'banned-ips.json', + 'craftdock_players_history.json', 'craftdock_players.json', + 'server.properties' +]; +for (const file of FILES_TO_CHMOD) { + const p = path.join(config.mcDataPath, file); + fsp.chmod(p, 0o666).catch(() => {}); +} +const docker = new Docker(); // uses /var/run/docker.sock by default + +const dockerService = createDockerService(config, docker, logger.child('docker')); + +const rconService = createRconService(config, undefined, logger.child('rcon')); +const stdinService = createStdinService(dockerService, { logger: logger.child('stdin') }); +const propertiesService = createPropertiesService(config); +const authService = createAuthService(config); +const seedService = createSeedService({ config, propertiesService, logger: logger.child('seed') }); +const appState = createAppState({ config, dockerService, rconService, stdinService }); + +// First boot of this data volume only: force the whitelist off (see service). +await ensureFirstBootDefaults({ config, propertiesService, logger: logger.child('first-boot') }); + +const extractZip = (zip, dest) => + new Promise((resolve, reject) => { + createReadStream(zip) + .pipe(unzipper.Extract({ path: dest })) + .on('close', resolve) + .on('error', reject); + }); + +const worldService = createWorldService({ config, dockerService, propertiesService, archiver, extractZip }); +const upload = multer({ + dest: os.tmpdir(), + limits: { fileSize: config.maxUploadBytes }, +}); + +logger.info('=== CraftDock Container Started ===', { + nodeEnv: config.nodeEnv, + port: config.port, + logLevel: process.env.LOG_LEVEL || 'info', + logFormat: process.env.LOG_FORMAT || 'text', + mcContainerName: config.mcContainerName, + mcServiceName: config.mcServiceName, + mcDataPath: config.mcDataPath, + mcEdition: config.mcEdition, + mcWorldName: config.mcWorldName, +}); + +startServer({ + config, dockerService, appState, propertiesService, + worldService, authService, upload, seedService, logger, +}); + diff --git a/src/middleware/auth.js b/src/middleware/auth.js new file mode 100644 index 0000000..082e729 --- /dev/null +++ b/src/middleware/auth.js @@ -0,0 +1,15 @@ +export function requireAuth(req, res, next) { + if (req.session && req.session.authed === true) return next(); + return res.status(401).json({ error: 'unauthorized' }); +} + +export function socketAuth(sessionMiddleware) { + return (socket, next) => { + sessionMiddleware(socket.request, {}, () => { + if (socket.request.session && socket.request.session.authed === true) { + return next(); + } + next(new Error('unauthorized')); + }); + }; +} diff --git a/src/public/css/styles.css b/src/public/css/styles.css new file mode 100644 index 0000000..aee9a62 --- /dev/null +++ b/src/public/css/styles.css @@ -0,0 +1,64 @@ +/* Custom styles for CraftDock */ + +/* Scrollbar customization */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-track { + background: rgba(15, 23, 42, 0.6); +} +::-webkit-scrollbar-thumb { + background: rgba(51, 65, 85, 0.8); + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: rgba(71, 85, 105, 0.9); +} + +/* Glassmorphism styling */ +.glass-panel { + background: rgba(30, 41, 59, 0.4); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px fill rgba(255, 255, 255, 0.05); +} + +/* Custom Tab Buttons */ +.tab-btn { + padding: 0.375rem 0.875rem; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + color: #94a3b8; /* slate-400 */ + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} +.tab-btn:hover { + color: #f1f5f9; /* slate-100 */ + background: rgba(30, 41, 59, 0.5); +} +.tab-btn.active { + background: #10b981; /* emerald-500 */ + color: #0f172a; /* slate-900 */ + font-weight: 600; + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2); +} + +/* Monospace Console Styling */ +#console { + font-family: 'JetBrains Mono', ui-monospace, monospace; + white-space: pre-wrap; + word-break: break-all; + line-height: 1.5; + background-color: #020617; /* slate-950 */ +} + +/* Smooth fade-in animation for panel updates */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +.fade-in { + animation: fadeIn 0.3s ease forwards; +} diff --git a/src/public/index.html b/src/public/index.html new file mode 100644 index 0000000..d4b5b50 --- /dev/null +++ b/src/public/index.html @@ -0,0 +1,85 @@ + + + + + + CraftDock + + + + + + + + + + + + + + + + + + diff --git a/src/public/js/app.js b/src/public/js/app.js new file mode 100644 index 0000000..6fdea4c --- /dev/null +++ b/src/public/js/app.js @@ -0,0 +1,87 @@ +import { api } from './socket.js'; +import { renderStatus } from './tabs/status.js'; +import { renderConsole } from './tabs/console.js'; +import { renderPlayers } from './tabs/players.js'; +import { renderOptions } from './tabs/options.js'; +import { renderWorld } from './tabs/world.js'; + +const TABS = [ + { id: 'status', label: 'Início', render: renderStatus }, + { id: 'console', label: 'Console', render: renderConsole }, + { id: 'players', label: 'Jogadores', render: renderPlayers }, + { id: 'options', label: 'Opções', render: renderOptions }, + { id: 'world', label: 'Mundo', render: renderWorld }, +]; + +let cleanup = null; + +function showTab(id) { + if (cleanup) { + cleanup(); + cleanup = null; + } + document.querySelectorAll('.tab-btn').forEach((b) => + b.classList.toggle('active', b.dataset.tab === id)); + + const panels = document.getElementById('panels'); + panels.innerHTML = ''; + + // Create a container with fade-in effect + const wrapper = document.createElement('div'); + wrapper.className = 'fade-in flex-grow flex flex-col'; + panels.appendChild(wrapper); + + const tab = TABS.find((t) => t.id === id); + cleanup = tab.render(wrapper) || null; +} + +function initTabs() { + const nav = document.getElementById('tabs'); + nav.innerHTML = ''; + for (const tab of TABS) { + const btn = document.createElement('button'); + btn.className = 'tab-btn'; + btn.dataset.tab = tab.id; + btn.textContent = tab.label; + btn.onclick = () => showTab(tab.id); + nav.appendChild(btn); + } + showTab('status'); +} + +async function boot() { + try { + const me = await api('/api/auth/me'); + if (me?.authed) { + document.getElementById('app').classList.remove('hidden'); + initTabs(); + } else { + document.getElementById('login').classList.remove('hidden'); + } + } catch (err) { + console.error('Boot error:', err); + document.getElementById('login').classList.remove('hidden'); + } +} + +document.getElementById('loginBtn').onclick = async () => { + const password = document.getElementById('password').value; + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + if (res.ok) { + window.location.reload(); + } else { + const errorEl = document.getElementById('loginError'); + errorEl.classList.remove('hidden'); + } +}; + +document.getElementById('logoutBtn').onclick = async () => { + await fetch('/api/auth/logout', { method: 'POST' }); + window.location.reload(); +}; + +boot(); diff --git a/src/public/js/socket.js b/src/public/js/socket.js new file mode 100644 index 0000000..a07b770 --- /dev/null +++ b/src/public/js/socket.js @@ -0,0 +1,17 @@ +export async function api(path, opts = {}) { + const headers = { ...opts.headers }; + if (opts.body && !(opts.body instanceof FormData)) { + headers['Content-Type'] = 'application/json'; + } + const res = await fetch(path, { + ...opts, + headers, + body: opts.body && !(opts.body instanceof FormData) ? JSON.stringify(opts.body) : opts.body, + }); + if (res.status === 401) { + window.location.reload(); + return; + } + const ct = res.headers.get('content-type') || ''; + return ct.includes('application/json') ? res.json() : res; +} diff --git a/src/public/js/tabs/console.js b/src/public/js/tabs/console.js new file mode 100644 index 0000000..70d612a --- /dev/null +++ b/src/public/js/tabs/console.js @@ -0,0 +1,59 @@ +export function renderConsole(root) { + root.innerHTML = ` +
+ +
+
+
+ + + +
+ interactive_console.sh +
+ Conectado +
+ + +
+ + +
+ > + + +
+
`; + + const consoleEl = root.querySelector('#console'); + const MAX = 500; + + const append = (text) => { + // Sanitize chunk styling a bit if necessary or just write raw + // Convert newlines to breaks or use textContent + consoleEl.textContent += text; + const lines = consoleEl.textContent.split('\n'); + if (lines.length > MAX) { + consoleEl.textContent = lines.slice(-MAX).join('\n'); + } + consoleEl.scrollTop = consoleEl.scrollHeight; + }; + + const socket = io('/logs'); + socket.on('log', append); + socket.on('connect_error', () => append('\n[craftdock] conexão perdida\n')); + + root.querySelector('#cmdForm').onsubmit = (e) => { + e.preventDefault(); + const input = root.querySelector('#cmd'); + const val = input.value.trim(); + if (val) { + socket.emit('command', val); + input.value = ''; + } + }; + + return () => socket.disconnect(); +} diff --git a/src/public/js/tabs/map.js b/src/public/js/tabs/map.js new file mode 100644 index 0000000..f2d0b9b --- /dev/null +++ b/src/public/js/tabs/map.js @@ -0,0 +1,91 @@ +import { api } from '../socket.js'; + +// mcseedmap.net is embeddable (no X-Frame-Options) and renders the REAL biome/ +// structure map from the seed. URL: https://mcseedmap.net/// +const DEFAULT_VERSION = { bedrock: '26.30.0-Bedrock', java: '1.21-Java' }; + +function mapUrlFor(seed, edition, mapVersion) { + const version = mapVersion || DEFAULT_VERSION[edition] || DEFAULT_VERSION.java; + return `https://mcseedmap.net/pt/${version}/${encodeURIComponent(seed)}`; +} + +export function renderMap(root) { + root.innerHTML = ` +
+ +
+ Seed do mundo + carregando… + + Abrir no mcseedmap ↗ + +
+ + +
+
+
+

Aguardando a seed do servidor…

+

O mapa aparece quando o servidor estiver ligado e a seed for lida.

+
+
+ +
+
`; + + const seedEl = root.querySelector('#seedVal'); + const copyBtn = root.querySelector('#copySeed'); + const openLink = root.querySelector('#openMap'); + const editionEl = root.querySelector('#edition'); + const frame = root.querySelector('#mapFrame'); + const placeholder = root.querySelector('#mapPlaceholder'); + + let currentSeed = null; + let edition = 'java'; + let mapVersion = ''; + + function applySeed(seed) { + if (seed == null || seed === '' || String(seed) === currentSeed) return; + currentSeed = String(seed); + seedEl.textContent = currentSeed; + copyBtn.disabled = false; + + const url = mapUrlFor(currentSeed, edition, mapVersion); + openLink.href = url; + openLink.classList.remove('pointer-events-none', 'opacity-40'); + + // Only (re)load the iframe when the seed actually changes, so panning/zoom + // inside mcseedmap isn't reset on every status tick. + frame.src = url; + frame.classList.remove('hidden'); + placeholder.classList.add('hidden'); + } + + copyBtn.onclick = async () => { + if (!currentSeed) return; + try { + await navigator.clipboard.writeText(currentSeed); + copyBtn.textContent = 'Copiado!'; + setTimeout(() => { copyBtn.textContent = 'Copiar'; }, 1500); + } catch { /* clipboard blocked */ } + }; + + // Initial config (seed + edition + optional version override). + api('/api/client-config').then((cfg) => { + if (!cfg) return; + if (cfg.edition) edition = cfg.edition; + if (cfg.mapVersion) mapVersion = cfg.mapVersion; + editionEl.textContent = cfg.edition ? `Edição: ${cfg.edition}` : ''; + if (cfg.seed) applySeed(cfg.seed); + }); + + // Keep listening — the seed becomes available once the server is running. + const socket = io('/status'); + socket.on('status', (s) => { + if (s.type) edition = s.type.toUpperCase() === 'BEDROCK' ? 'bedrock' : 'java'; + editionEl.textContent = s.edition ? `Edição: ${s.edition}` : editionEl.textContent; + if (s.seed) applySeed(s.seed); + }); + + return () => socket.disconnect(); +} diff --git a/src/public/js/tabs/options.js b/src/public/js/tabs/options.js new file mode 100644 index 0000000..44e082b --- /dev/null +++ b/src/public/js/tabs/options.js @@ -0,0 +1,271 @@ +import { api } from '../socket.js'; + +const CATEGORIES = [ + { + title: 'Jogabilidade & Regras', + icon: ``, + fields: [ + { + key: 'difficulty', + label: 'Dificuldade', + type: 'enum', + values: [ + { val: 'peaceful', label: 'Pacífico' }, + { val: 'easy', label: 'Fácil' }, + { val: 'normal', label: 'Normal' }, + { val: 'hard', label: 'Difícil' }, + ], + description: 'Define o nível de dificuldade do jogo, controlando dano de monstros, fome e geração de mobs hostis.', + }, + { + key: 'gamemode', + label: 'Modo de Jogo Padrão', + type: 'enum', + values: [ + { val: 'survival', label: 'Sobrevivência' }, + { val: 'creative', label: 'Criativo' }, + { val: 'adventure', label: 'Aventura' }, + { val: 'spectator', label: 'Espectador' }, + ], + description: 'Determina o modo de jogo atribuído aos novos jogadores que se conectarem ao mundo.', + }, + + { + key: 'pvp', + label: 'Habilitar PvP (Combate)', + type: 'boolean', + description: 'Permite que jogadores ataquem e causem dano a outros jogadores no servidor.', + }, + { + key: 'keep-inventory', + label: 'Manter Itens ao Morrer (Keep Inventory)', + type: 'boolean', + description: 'Quando ativado, os jogadores não perdem inventário nem pontos de experiência ao morrer.', + }, + { + key: 'hardcore', + label: 'Modo Hardcore', + type: 'boolean', + description: 'Dificuldade travada no máximo e morte permanente (jogadores entram em modo espectador após morrer).', + }, + { + key: 'allow-flight', + label: 'Permitir Voar (Flight)', + type: 'boolean', + description: 'Permite voo no modo Sobrevivência se o jogador possuir um mod instalado, evitando que o servidor o desconecte por "kick for flying".', + }, + { + key: 'force-gamemode', + label: 'Forçar Modo de Jogo ao Entrar', + type: 'boolean', + description: 'Força o jogador a redefinir seu modo para o padrão do servidor toda vez que se reconectar.', + }, + ], + }, + { + title: 'Mundo & Entidades', + icon: ``, + fields: [ + { + key: 'allow-nether', + label: 'Permitir Dimensão Nether', + type: 'boolean', + description: 'Habilita ou desabilita a criação e teletransporte através de portais do Nether.', + }, + { + key: 'enable-command-block', + label: 'Ativar Blocos de Comando', + type: 'boolean', + description: 'Permite a execução de comandos dentro de Blocos de Comando (Command Blocks).', + }, + { + key: 'spawn-monsters', + label: 'Gerar Monstros (Hostis)', + type: 'boolean', + description: 'Controla se criaturas hostis (Creepers, Zumbis, Esqueletos) aparecem naturalmente no mundo.', + }, + { + key: 'spawn-animals', + label: 'Gerar Animais (Pacíficos)', + type: 'boolean', + description: 'Controla se animais (Vacas, Ovelhas, Galinhas) surgem no mundo.', + }, + { + key: 'spawn-npcs', + label: 'Gerar NPCs / Aldeões', + type: 'boolean', + description: 'Controla a criação e permanência de Villagers (Aldeões) nas vilas.', + }, + ], + }, + + { + title: 'Desempenho, Limites e Apresentação', + icon: ``, + fields: [ + { + key: 'max-players', + label: 'Limite Máximo de Jogadores', + type: 'int', + description: 'Número máximo de conexões simultâneas permitidas no servidor.', + }, + { + key: 'view-distance', + label: 'Distância de Visão (Chunks)', + type: 'int', + description: 'Quantidade de chunks enviadas para o cliente do jogador em um raio em redor dele.', + }, + { + key: 'simulation-distance', + label: 'Distância de Simulação (Chunks)', + type: 'int', + description: 'Raio de chunks em que blocos, vegetação e entidades permanecem fisicamente ativos e processando.', + }, + { + key: 'spawn-protection', + label: 'Proteção do Spawn (Blocos)', + type: 'int', + description: 'Raio de blocos ao redor do ponto de spawn onde apenas Operadores (OP) podem construir ou quebrar.', + }, + { + key: 'motd', + label: 'Mensagem do Dia (MOTD)', + type: 'string', + description: 'Texto de exibição do servidor na lista de mundos dos clientes.', + }, + ], + }, +]; + +export function renderOptions(root) { + root.innerHTML = ` +
+
+

+ + Configurações do Servidor +

+ server.properties +
+ +
+
Carregando propriedades do servidor...
+
+ +
+ + + Algumas alterações exigem reinicialização do servidor. + +
+
+
`; + + function renderLabelWithTooltip(f) { + return ` +
+ ${f.label || f.key} + ? + + +
`; + } + + function renderField(f, value) { + const labelHtml = renderLabelWithTooltip(f); + + if (f.type === 'enum') { + const optionsHtml = f.values.map((v) => { + const val = typeof v === 'object' ? v.val : v; + const lbl = typeof v === 'object' ? v.label : v; + return ``; + }).join(''); + return ` +
+ ${labelHtml} + +
`; + } + + + if (f.type === 'boolean') { + const isChecked = value === 'true'; + return ` +
+ ${labelHtml} + +
`; + } + + const inputType = f.type === 'int' ? 'number' : 'text'; + return ` +
+ ${labelHtml} + +
`; + } + + api('/api/properties').then((data) => { + if (!data) return; + const props = data.properties || {}; + const form = root.querySelector('#opts'); + + let html = ''; + for (const cat of CATEGORIES) { + html += ` +
+

+ ${cat.icon} + ${cat.title} +

+
+ ${cat.fields.map((f) => renderField(f, props[f.key])).join('')} +
+
`; + } + + html += ` +
+ +
`; + + form.innerHTML = html; + + form.onsubmit = async (e) => { + e.preventDefault(); + const patch = {}; + const allFields = CATEGORIES.flatMap((c) => c.fields); + for (const f of allFields) { + const el = form.elements[f.key]; + if (!el) continue; + patch[f.key] = f.type === 'boolean' ? String(el.checked) : el.value; + } + + const res = await api('/api/properties', { method: 'PUT', body: { properties: patch } }); + const msg = root.querySelector('#optMsg'); + + if (res?.ok) { + msg.textContent = 'Configurações salvas com sucesso!'; + msg.className = 'text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 px-3.5 py-2 rounded-xl flex items-center gap-1.5'; + } else { + msg.textContent = `Erro ao salvar o campo: ${res?.field || 'geral'}`; + msg.className = 'text-red-400 text-sm bg-red-500/10 border border-red-500/20 px-3.5 py-2 rounded-xl flex items-center gap-1.5'; + } + }; + }); +} + diff --git a/src/public/js/tabs/players.js b/src/public/js/tabs/players.js new file mode 100644 index 0000000..a037cf4 --- /dev/null +++ b/src/public/js/tabs/players.js @@ -0,0 +1,381 @@ +import { api } from '../socket.js'; + +const PAGE_SIZE = 25; + +export function renderPlayers(root) { + root.innerHTML = ` +
+ + +
+
+

+ + Jogadores do Mundo +

+ 0 online +
+ + +
+
+ +
    +
  • Carregando...
  • +
+ + +
+ + +
+
+

+ + Allowlist +

+ +
+

+ + + +
+ Membros + 0 +
+
    +
  • Carregando...
  • +
+
+ + +
+
`; + + let capabilities = new Set(); + let currentWhitelistEnabled = false; + let page = 1; + let query = ''; + + const action = (act, body) => api(`/api/players/${act}`, { method: 'POST', body }).then(() => { refresh(); loadDirectory(); }); + const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + + // --- Directory (server-side paginated) --- + + // Per-player actions instead of type-a-name forms: primary buttons inline, + // privilege actions (op/deop/ban) behind a native
kebab menu. + function rowActions(e) { + const iconBtn = (data, title, cls, path) => ` + `; + + const detail = e.xuid + ? `` + : ''; + const online = e.online + ? iconBtn(`data-tp="${esc(e.name)}"`, 'Teleportar', 'bg-blue-600/10 hover:bg-blue-600 border-blue-500/20 hover:border-blue-500 text-blue-400 hover:text-slate-950', 'M13 10V3L4 14h7v7l9-11h-7z') + + iconBtn(`data-kick="${esc(e.name)}"`, 'Expulsar', 'bg-red-600/10 hover:bg-red-600 border-red-500/20 hover:border-red-600 text-red-400 hover:text-white', 'M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1') + : ''; + + const menuItem = (act, label, danger = false) => capabilities.has(act) + ? `` + : ''; + const items = menuItem('op', 'Tornar OP') + menuItem('deop', 'Remover OP') + menuItem('ban', 'Banir jogador', true); + const menu = items + ? `
+ + + +
${items}
+
` + : ''; + return detail + online + menu; + } + + async function loadDirectory() { + const data = await api(`/api/players/directory?page=${page}&pageSize=${PAGE_SIZE}&q=${encodeURIComponent(query)}`); + if (!data) return; + const list = root.querySelector('#directory'); + // Don't re-render under an open kebab menu (the 5s poll would close it). + if (list.querySelector('[data-kebab][open]')) return; + const items = data.items || []; + + list.innerHTML = items.length + ? items.map((e) => { + const initials = esc(e.name.substring(0, 2).toUpperCase()); + const dot = e.online + ? `` + : ''; + const avatar = e.online ? 'bg-emerald-500/10 text-emerald-400' : 'bg-slate-800/40 text-slate-500'; + const nameCls = e.online ? 'text-slate-200' : 'text-slate-400'; + const statusLine = `${e.online ? 'Online' : 'Offline'}${e.xuid ? ` · ${esc(e.xuid)}` : ''}`; + return ` +
  • +
    +
    ${initials}${dot}
    +
    + ${esc(e.name)} + ${statusLine} +
    +
    +
    ${rowActions(e)}
    +
  • `; + }).join('') + : `
    + + ${query ? 'Nenhum jogador corresponde ao filtro' : 'Nenhum jogador encontrado'} +
    `; + + // Pagination controls + const totalPages = Math.max(1, Math.ceil((data.total || 0) / PAGE_SIZE)); + const pag = root.querySelector('#pagination'); + pag.innerHTML = ` + + Página ${data.page} de ${totalPages} · ${data.total || 0} jogador${(data.total || 0) === 1 ? '' : 'es'} + `; + + pag.querySelector('[data-page="prev"]').onclick = () => { if (page > 1) { page--; loadDirectory(); } }; + pag.querySelector('[data-page="next"]').onclick = () => { if (page < totalPages) { page++; loadDirectory(); } }; + + list.querySelectorAll('[data-kick]').forEach((b) => { b.onclick = () => action('kick', { name: b.dataset.kick }); }); + list.querySelectorAll('[data-tp]').forEach((b) => { + b.onclick = () => { const t = prompt('Teleportar para (jogador ou coordenadas x y z):'); if (t) action('teleport', { name: b.dataset.tp, target: t }); }; + }); + list.querySelectorAll('[data-detail]').forEach((b) => { b.onclick = () => openDetail(b.dataset.detail, b.dataset.name); }); + list.querySelectorAll('[data-menu-act]').forEach((b) => { + b.onclick = () => { + const { menuAct, menuName } = b.dataset; + b.closest('details')?.removeAttribute('open'); + if (menuAct === 'ban' && !confirm(`Banir ${menuName} permanentemente do servidor?`)) return; + action(menuAct, { name: menuName }); + }; + }); + // Only one kebab menu open at a time; close on outside click. + list.querySelectorAll('[data-kebab]').forEach((d) => { + d.addEventListener('toggle', () => { + if (d.open) list.querySelectorAll('[data-kebab][open]').forEach((o) => { if (o !== d) o.removeAttribute('open'); }); + }); + }); + } + root.addEventListener('click', (ev) => { + if (!ev.target.closest('[data-kebab]')) root.querySelectorAll('[data-kebab][open]').forEach((o) => o.removeAttribute('open')); + }); + + // --- Detail modal --- + let modalTimer = null; + function closeModal() { + if (modalTimer) { clearInterval(modalTimer); modalTimer = null; } + root.querySelector('#detail-modal').innerHTML = ''; + document.removeEventListener('keydown', onEsc); + } + function onEsc(e) { if (e.key === 'Escape') closeModal(); } + + function bar(label, value, max, colorClass) { + const pct = Math.max(0, Math.min(100, (value / max) * 100)); + return ` +
    +
    ${label}${value} / ${max}
    +
    +
    `; + } + + // --- Minecraft-style inventory rendering --- + // Slot layout follows the game: 0-8 hotbar, 9-35 main grid (3 rows of 9). + const ICON_BASE = '/assets/mc/items'; + const shortName = (n) => String(n || '').replace(/^minecraft:/, ''); + const prettyName = (n) => shortName(n).replace(/_/g, ' '); + + function slotHtml(it, { placeholder = '' } = {}) { + const inner = it && it.name + ? ` + ${it.count > 1 ? `${it.count}` : ''}` + : placeholder; + const title = it && it.name ? ` title="${esc(prettyName(it.name))}${it.count > 1 ? ` ×${it.count}` : ''}"` : ''; + return `
    ${inner}
    `; + } + + function inventoryHtml(d) { + const bySlot = new Map((d.inventory || []).filter((i) => i && i.name).map((i) => [i.slot, i])); + const row = (from, to) => { + let cells = ''; + for (let s = from; s <= to; s++) cells += slotHtml(bySlot.get(s)); + return `
    ${cells}
    `; + }; + const armor = d.armor || {}; + const armorIcons = { head: '🪖', chest: '🦺', legs: '👖', feet: '🥾' }; + const equipment = ['head', 'chest', 'legs', 'feet'] + .map((k) => slotHtml(armor[k], { placeholder: `${armorIcons[k]}` })) + .join(''); + return ` +
    +
    +
    ${equipment}
    +
    + Escudo + ${slotHtml(d.offhand, { placeholder: '🛡️' })} +
    +
    + ${row(9, 17)}${row(18, 26)}${row(27, 35)} +
    ${row(0, 8)}
    +
    + `; + } + + function renderModalBody(d, name) { + if (d.needsBridge) { + return `

    Abra este jogador uma vez enquanto ele está online para vincular os dados do save (o Bedrock não liga o nome ao arquivo até o primeiro acesso observado).

    `; + } + if (d.empty || !d.position) { + return `

    Sem dados salvos para este jogador ainda.

    `; + } + const dimLabel = { overworld: 'Overworld', nether: 'Nether', end: 'End' }[d.dimension] || d.dimension; + const p = d.position; + const invGrid = inventoryHtml(d); + + return ` +
    +
    +
    +
    Posição
    +
    ${Math.round(p.x)}, ${Math.round(p.y)}, ${Math.round(p.z)}
    +
    ${dimLabel}
    +
    +
    + ${d.health ? bar('Vida', Math.round(d.health.current * 10) / 10, d.health.max, 'bg-red-500') : ''} + ${typeof d.food === 'number' ? bar('Fome', d.food, 20, 'bg-amber-500') : ''} +
    +
    +
    +
    Inventário${d.xp ? ` · Nível ${d.xp.level}` : ''}${d.gamemode ? ` · ${esc(d.gamemode)}` : ''}
    +
    ${invGrid}
    +
    +
    `; + } + + async function openDetail(xuid, name) { + const host = root.querySelector('#detail-modal'); + const shell = (body, footer = '') => ` +
    +
    +
    +
    +
    ${esc(name.substring(0, 2).toUpperCase())}
    +
    +
    ${esc(name)}
    +
    ${esc(xuid)}
    +
    +
    + +
    +
    ${body}
    +
    ${footer}
    +
    +
    `; + + host.innerHTML = shell('

    Carregando dados do save...

    '); + document.addEventListener('keydown', onEsc); + + const wire = () => { + host.querySelector('[data-close]').onclick = closeModal; + host.querySelector('[data-backdrop]').onclick = (ev) => { if (ev.target === ev.currentTarget) closeModal(); }; + const rb = host.querySelector('[data-refresh]'); + if (rb) rb.onclick = () => load(); + }; + wire(); + + async function load() { + const d = await api(`/api/players/detail?xuid=${encodeURIComponent(xuid)}`); + if (!d) { host.innerHTML = shell('

    Falha ao ler os dados do jogador.

    ', footerHtml('')); wire(); return; } + host.innerHTML = shell(renderModalBody(d, name), footerHtml(d)); + wire(); + } + function footerHtml(d) { + const online = d && d.online ? '● Online' : 'Offline'; + const saved = d && d.savedAt ? `atualizado às ${new Date(d.savedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : ''; + return `${online}${saved}`; + } + await load(); + } + + // --- Allowlist card (single home for toggle + add + members) --- + function renderAllowlist(data) { + currentWhitelistEnabled = data.whitelistEnabled; + const toggle = root.querySelector('#allowlist-toggle'); + const knob = root.querySelector('#allowlist-knob'); + const hint = root.querySelector('#allowlist-hint'); + toggle.classList.toggle('bg-emerald-600', currentWhitelistEnabled); + toggle.classList.toggle('bg-slate-700', !currentWhitelistEnabled); + knob.style.transform = currentWhitelistEnabled ? 'translateX(1.5rem)' : 'translateX(0.25rem)'; + hint.textContent = currentWhitelistEnabled + ? 'Ativa — apenas os membros abaixo podem entrar.' + : 'Desativada — qualquer jogador pode entrar no servidor.'; + toggle.onclick = () => action(currentWhitelistEnabled ? 'whitelistOff' : 'whitelistOn', {}); + + const addForm = root.querySelector('#allowlist-add'); + addForm.classList.toggle('hidden', !capabilities.has('whitelistAdd')); + addForm.classList.toggle('flex', capabilities.has('whitelistAdd')); + addForm.onsubmit = (e) => { + e.preventDefault(); + const input = addForm.querySelector('input[name="name"]'); + if (input.value.trim()) action('whitelistAdd', { name: input.value.trim() }); + input.value = ''; + }; + + const list = root.querySelector('#allowlist-list'); + const members = data.whitelist || []; + root.querySelector('#allowlist-count').textContent = members.length; + list.innerHTML = members.length + ? members.map((name) => ` +
  • +
    ${esc(name.substring(0, 2).toUpperCase())}
    + ${esc(name)} + ${capabilities.has('whitelistRemove') ? `` : ''} +
  • `).join('') + : '
  • Nenhum jogador na allowlist.
  • '; + list.querySelectorAll('[data-wl-remove]').forEach((b) => { b.onclick = () => action('whitelistRemove', { name: b.dataset.wlRemove }); }); + } + + async function refresh() { + const data = await api('/api/players'); + if (!data) return; + capabilities = new Set(data.capabilities); + root.querySelector('#player-count').textContent = `${data.players?.online || 0}/${data.players?.max || 20} online`; + renderAllowlist(data); + } + + // Search → server-side query, debounced, resets to page 1. + let searchTimer = null; + root.querySelector('#player-search').addEventListener('input', (e) => { + clearTimeout(searchTimer); + searchTimer = setTimeout(() => { query = e.target.value.trim(); page = 1; loadDirectory(); }, 300); + }); + + refresh(); + loadDirectory(); + const timer = setInterval(() => { refresh(); loadDirectory(); }, 5000); + return () => { clearInterval(timer); closeModal(); }; +} diff --git a/src/public/js/tabs/status.js b/src/public/js/tabs/status.js new file mode 100644 index 0000000..777597f --- /dev/null +++ b/src/public/js/tabs/status.js @@ -0,0 +1,115 @@ +import { api } from '../socket.js'; + +export function renderStatus(root) { + root.innerHTML = ` +
    + +
    +
    +
    + + +
    +
    +
    + Carregando... + +
    +

    Status do servidor em tempo real

    +
    +
    + +
    + + + + +
    +
    + + +
    + +
    +
    + Processador +
    + +
    +
    +
    –
    +

    Uso de CPU do container

    +
    + + +
    +
    + Memória RAM +
    + +
    +
    +
    –
    +

    Utilização da memória física

    +
    + + +
    +
    + Jogadores +
    + +
    +
    +
    –
    +

    Conectados atualmente

    +
    +
    +
    `; + + root.querySelectorAll('button[data-act]').forEach((b) => { + b.onclick = () => api(`/api/status/${b.dataset.act}`, { method: 'POST' }); + }); + + const socket = io('/status'); + const pingEl = root.querySelector('#state-ping'); + const dotEl = root.querySelector('#state-dot'); + + socket.on('status', (s) => { + const stateEl = root.querySelector('#state'); + stateEl.textContent = s.state; + root.querySelector('#edition').textContent = s.edition ? `Edição: ${s.edition}` : ''; + root.querySelector('#cpu').textContent = s.state === 'running' ? `${s.cpuPct}%` : '–'; + root.querySelector('#mem').textContent = s.state === 'running' ? `${s.memUsedMb} MB` : '–'; + root.querySelector('#players').textContent = s.players ? `${s.players.online}/${s.players.max}` : '–'; + + // Color code ping & dot based on state + if (s.state === 'running') { + pingEl.className = 'animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 bg-emerald-500'; + dotEl.className = 'relative inline-flex rounded-full h-3 w-3 bg-emerald-500'; + stateEl.className = 'text-lg font-semibold capitalize text-emerald-400'; + } else if (s.state === 'starting' || s.state === 'stopping' || s.state === 'restarting') { + pingEl.className = 'animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 bg-amber-500'; + dotEl.className = 'relative inline-flex rounded-full h-3 w-3 bg-amber-500'; + stateEl.className = 'text-lg font-semibold capitalize text-amber-400'; + } else { + pingEl.className = 'absolute inline-flex h-full w-full rounded-full opacity-75 bg-rose-500'; + dotEl.className = 'relative inline-flex rounded-full h-3 w-3 bg-rose-500'; + stateEl.className = 'text-lg font-semibold capitalize text-rose-400'; + } + }); + + return () => socket.disconnect(); +} diff --git a/src/public/js/tabs/world.js b/src/public/js/tabs/world.js new file mode 100644 index 0000000..785318b --- /dev/null +++ b/src/public/js/tabs/world.js @@ -0,0 +1,237 @@ +import { api } from '../socket.js'; + +// mcseedmap.net is embeddable (no X-Frame-Options) and renders the REAL biome/ +// structure map from the seed. URL: https://mcseedmap.net/// +const DEFAULT_VERSION = { bedrock: '26.30.0-Bedrock', java: '1.21-Java' }; + +function mapUrlFor(seed, edition, mapVersion) { + const version = mapVersion || DEFAULT_VERSION[edition] || DEFAULT_VERSION.java; + return `https://mcseedmap.net/pt/${version}/${encodeURIComponent(seed)}`; +} + +export function renderWorld(root) { + root.innerHTML = ` +
    + + +
    + + +
    +
    +
    +
    + +
    +

    Baixar mundo

    +
    +

    Faça o download do seu mapa atual compactado em formato .zip.

    +
    + + Recomendado desligar o servidor para evitar corrupção de arquivos. +
    +
    + + Baixar .zip do Mundo + +
    + + +
    +
    +
    +
    +
    + +
    +

    Subir novo mundo

    +
    +

    Substitua o mapa do servidor enviando um arquivo .zip contendo o novo mundo.

    + +
    + + +
    +
    +

    +
    + + +
    + + +
    +
    +
    +
    +
    + +
    +

    Regerar mundo

    +
    +

    Esta ação apaga permanentemente o mundo ativo e força a criação de um mapa novo.

    + +
    + + +
    +
    + +
    + + Esta operação é IRREVERSÍVEL. Faça backup antes. +
    +
    + +
    + +
    + + +
    + +
    +
    + + Seed do mundo +
    + carregando… + + Abrir no mcseedmap ↗ + +
    + + +
    +
    +
    + +

    Aguardando a seed do servidor…

    +

    O mapa aparece quando o servidor estiver ligado e a seed for lida.

    +
    +
    + +
    +
    + +
    `; + + // --- World management handlers --- + + const fileInput = root.querySelector('#worldFile'); + const fileName = root.querySelector('#fileName'); + fileInput.onchange = () => { + if (fileInput.files.length) { + fileName.textContent = fileInput.files[0].name; + fileName.classList.remove('text-slate-400'); + fileName.classList.add('text-emerald-400', 'font-semibold'); + } else { + fileName.textContent = 'Selecionar arquivo .zip'; + fileName.classList.remove('text-emerald-400', 'font-semibold'); + fileName.classList.add('text-slate-400'); + } + }; + + root.querySelector('#regenBtn').onclick = async () => { + const seed = root.querySelector('#regenSeed').value.trim(); + if (!confirm('Isto APAGA o mundo atual. Continuar?')) return; + if (!confirm('Tem certeza absoluta? Não há como desfazer.')) return; + await api('/api/world/regen', { method: 'POST', body: { seed } }); + alert('Novo mundo sendo gerado. O servidor irá reiniciar.'); + }; + + root.querySelector('#uploadBtn').onclick = async () => { + const file = fileInput.files[0]; + if (!file) return; + if (!confirm('Isto substitui o mundo atual pelo novo arquivo enviado. Continuar?')) return; + const msg = root.querySelector('#upMsg'); + msg.textContent = 'Enviando...'; + msg.className = 'text-xs text-center mt-2.5 text-blue-400 font-semibold'; + + const fd = new FormData(); + fd.append('world', file); + + try { + const res = await fetch('/api/world/upload', { method: 'POST', body: fd }); + if (res.ok) { + msg.textContent = 'Mundo importado. Servidor reiniciando.'; + msg.className = 'text-xs text-center mt-2.5 text-emerald-400 font-semibold bg-emerald-500/10 border border-emerald-500/20 py-1.5 rounded-lg'; + fileInput.value = ''; + fileName.textContent = 'Selecionar arquivo .zip'; + fileName.classList.remove('text-emerald-400', 'font-semibold'); + fileName.classList.add('text-slate-400'); + } else { + msg.textContent = 'Falha: arquivo inválido.'; + msg.className = 'text-xs text-center mt-2.5 text-rose-450 font-semibold bg-rose-500/10 border border-rose-500/20 py-1.5 rounded-lg'; + } + } catch { + msg.textContent = 'Falha de conexão ao enviar.'; + msg.className = 'text-xs text-center mt-2.5 text-rose-450 font-semibold bg-rose-500/10 border border-rose-500/20 py-1.5 rounded-lg'; + } + }; + + // --- Seed map handlers --- + + const seedEl = root.querySelector('#seedVal'); + const copyBtn = root.querySelector('#copySeed'); + const openLink = root.querySelector('#openMap'); + const editionEl = root.querySelector('#edition'); + const frame = root.querySelector('#mapFrame'); + const placeholder = root.querySelector('#mapPlaceholder'); + + let currentSeed = null; + let edition = 'java'; + let mapVersion = ''; + + function applySeed(seed) { + if (seed == null || seed === '' || String(seed) === currentSeed) return; + currentSeed = String(seed); + seedEl.textContent = currentSeed; + copyBtn.disabled = false; + + const url = mapUrlFor(currentSeed, edition, mapVersion); + openLink.href = url; + openLink.classList.remove('pointer-events-none', 'opacity-40'); + + // Only (re)load the iframe when the seed actually changes, so panning/zoom + // inside mcseedmap isn't reset on every status tick. + frame.src = url; + frame.classList.remove('hidden'); + placeholder.classList.add('hidden'); + } + + copyBtn.onclick = async () => { + if (!currentSeed) return; + try { + await navigator.clipboard.writeText(currentSeed); + copyBtn.textContent = 'Copiado!'; + setTimeout(() => { copyBtn.textContent = 'Copiar'; }, 1500); + } catch { /* clipboard blocked */ } + }; + + // Initial config (seed + edition + optional version override). + api('/api/client-config').then((cfg) => { + if (!cfg) return; + if (cfg.edition) edition = cfg.edition; + if (cfg.mapVersion) mapVersion = cfg.mapVersion; + editionEl.textContent = cfg.edition ? `Edição: ${cfg.edition}` : ''; + if (cfg.seed) applySeed(cfg.seed); + }); + + // Keep listening — the seed becomes available once the server is running. + const socket = io('/status'); + socket.on('status', (s) => { + if (s.type) edition = s.type.toUpperCase() === 'BEDROCK' ? 'bedrock' : 'java'; + editionEl.textContent = s.edition ? `Edição: ${s.edition}` : editionEl.textContent; + if (s.seed) applySeed(s.seed); + }); + + return () => socket.disconnect(); +} diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..d3b0fc7 --- /dev/null +++ b/src/routes/auth.js @@ -0,0 +1,22 @@ +import { Router } from 'express'; + +export function createAuthRouter(authService) { + const router = Router(); + + router.post('/login', async (req, res) => { + const ok = await authService.verifyPassword(req.body?.password); + if (!ok) return res.status(401).json({ error: 'invalid credentials' }); + req.session.authed = true; + res.json({ ok: true }); + }); + + router.post('/logout', (req, res) => { + req.session.destroy(() => res.json({ ok: true })); + }); + + router.get('/me', (req, res) => { + res.json({ authed: req.session?.authed === true }); + }); + + return router; +} diff --git a/src/routes/players.js b/src/routes/players.js new file mode 100644 index 0000000..d92331d --- /dev/null +++ b/src/routes/players.js @@ -0,0 +1,140 @@ +import { Router } from 'express'; +import { NotSupportedError } from '../adapters/serverAdapter.js'; +import { resolveName, queryDirectory, readDirectory } from '../services/playerDirectory.js'; +import { createPlayerFileAdapter } from '../adapters/playerFile/index.js'; +import { createBedrockIdentityBridge } from '../services/bedrockIdentityBridge.js'; +import { createPlayerDetailService } from '../services/playerDetailService.js'; + +const ARG_MAP = { + whitelistAdd: (b) => [b.name], + whitelistRemove: (b) => [b.name], + whitelistOn: () => [], + whitelistOff: () => [], + op: (b) => [b.name], + deop: (b) => [b.name], + pardon: (b) => [b.name], + ban: (b) => [b.name, b.reason || ''], + kick: (b) => [b.name, b.reason || ''], + give: (b) => [b.name, b.item, Number(b.count) || 1], + gamemode: (b) => [b.name, b.mode], + teleport: (b) => [b.name, b.target], +}; + +// Actions whose first argument is a player name. Guarding against an empty name +// stops commands like `allowlist remove ` (which the server rejects) from ever +// being sent — defense in depth against a bad/ghost name reaching the console. +const NAME_REQUIRED = new Set([ + 'whitelistAdd', 'whitelistRemove', 'op', 'deop', 'pardon', 'ban', 'kick', 'give', 'gamemode', 'teleport', +]); + +/** Returns the server.properties key for the whitelist toggle. */ +function whitelistPropKey(edition) { + return edition === 'bedrock' ? 'allow-list' : 'white-list'; +} + +export function createPlayersRouter({ appState, propertiesService, config, dockerService }) { + const router = Router(); + const dataRoot = config?.mcDataPath || '/minecraft/data'; + + const detailService = createPlayerDetailService({ + config, appState, dockerService, + readDirectory, + createFileAdapter: createPlayerFileAdapter, + createBridge: createBedrockIdentityBridge, + }); + + // Online players + whitelist + capabilities. The (potentially huge) directory + // is NOT embedded here — it is fetched page-by-page from GET /directory. + router.get('/', async (req, res, next) => { + try { + const adapter = await appState.getAdapter(); + const [players, whitelist, props] = await Promise.all([ + adapter.listPlayers(), + adapter.whitelistList(), + propertiesService.read(), + ]); + const edition = adapter._edition; + const propKey = whitelistPropKey(edition); + const whitelistEnabled = props[propKey] === 'true'; + res.json({ + players, + capabilities: [...adapter.capabilities], + whitelistEnabled, + whitelist, + }); + } catch (err) { next(err); } + }); + + // Server-side paginated + filtered directory; each item annotated `online`. + router.get('/directory', async (req, res, next) => { + try { + const page = Number(req.query.page) || 1; + const pageSize = Number(req.query.pageSize) || 25; + const q = req.query.q || ''; + const result = await queryDirectory(dataRoot, { page, pageSize, q }); + let onlineNames = []; + try { + const adapter = await appState.getAdapter(); + onlineNames = (await adapter.listPlayers())?.players || []; + } catch { /* server may be down — everyone shows offline */ } + const online = new Set(onlineNames); + res.json({ + ...result, + items: result.items.map((e) => ({ ...e, online: online.has(e.name) })), + }); + } catch (err) { next(err); } + }); + + // Rich per-player data read from the world save (position/health/food/inventory). + router.get('/detail', async (req, res, next) => { + try { + const xuid = String(req.query.xuid || ''); + if (!xuid) return res.status(400).json({ error: 'xuid_required' }); + const detail = await detailService.getDetail(xuid); + if (!detail) return res.status(404).json({ error: 'not_found' }); + res.json(detail); + } catch (err) { next(err); } + }); + + router.post('/:action', async (req, res) => { + const { action } = req.params; + const argsFn = ARG_MAP[action]; + if (!argsFn) return res.status(400).json({ error: 'unknown_action' }); + + req.body = req.body || {}; + // XUID is the canonical identity. When the client targets a known player by + // xuid, resolve it to the current gamertag here — Bedrock console commands are + // name-based, so every action is ultimately issued by name. + if (req.body.xuid && !String(req.body.name ?? '').trim()) { + const name = await resolveName(dataRoot, String(req.body.xuid)); + if (name) req.body.name = name; + } + + if (NAME_REQUIRED.has(action) && !String(req.body?.name ?? '').trim()) { + return res.status(400).json({ error: 'name_required' }); + } + try { + const adapter = await appState.getAdapter(); + if (typeof adapter[action] !== 'function') { + return res.status(400).json({ error: 'unknown_action' }); + } + const output = await adapter[action](...argsFn(req.body || {})); + + // Persist toggle state to server.properties + if (action === 'whitelistOn' || action === 'whitelistOff') { + const propKey = whitelistPropKey(adapter._edition); + const value = action === 'whitelistOn' ? 'true' : 'false'; + await propertiesService.update({ [propKey]: value }); + } + + res.json({ ok: true, output }); + } catch (err) { + if (err instanceof NotSupportedError) { + return res.status(409).json({ error: 'not_supported' }); + } + res.status(500).json({ error: err.message }); + } + }); + + return router; +} diff --git a/src/routes/properties.js b/src/routes/properties.js new file mode 100644 index 0000000..06551b9 --- /dev/null +++ b/src/routes/properties.js @@ -0,0 +1,75 @@ +import { Router } from 'express'; + +// Managed keys: type drives validation; `default` (vanilla server default) is +// filled into GET responses when the key is missing from server.properties, so +// the Options tab always starts fully populated. motd intentionally has none. +export const PROPERTY_SCHEMA = { + difficulty: { type: 'enum', values: ['peaceful', 'easy', 'normal', 'hard'], default: 'normal' }, + gamemode: { type: 'enum', values: ['survival', 'creative', 'adventure', 'spectator'], default: 'survival' }, + pvp: { type: 'boolean', default: 'true' }, + 'allow-flight': { type: 'boolean', default: 'false' }, + hardcore: { type: 'boolean', default: 'false' }, + 'keep-inventory': { type: 'boolean', default: 'false' }, + 'allow-nether': { type: 'boolean', default: 'true' }, + 'enable-command-block': { type: 'boolean', default: 'false' }, + 'force-gamemode': { type: 'boolean', default: 'false' }, + 'spawn-monsters': { type: 'boolean', default: 'true' }, + 'spawn-animals': { type: 'boolean', default: 'true' }, + 'spawn-npcs': { type: 'boolean', default: 'true' }, + 'max-players': { type: 'int', default: '20' }, + 'view-distance': { type: 'int', default: '10' }, + 'simulation-distance': { type: 'int', default: '10' }, + 'spawn-protection': { type: 'int', default: '16' }, + motd: { type: 'string' }, + 'level-name': { type: 'string' }, + 'level-seed': { type: 'string' }, +}; + +// Merge schema defaults under the actual file contents (file always wins). +export function withDefaults(properties) { + const out = { ...properties }; + for (const [key, schema] of Object.entries(PROPERTY_SCHEMA)) { + if (schema.default !== undefined && out[key] === undefined) out[key] = schema.default; + } + return out; +} + +function validate(properties) { + for (const [key, value] of Object.entries(properties)) { + const schema = PROPERTY_SCHEMA[key]; + if (!schema) continue; // unmanaged keys pass through + const str = String(value); + if (schema.type === 'enum' && !schema.values.includes(str)) return key; + if (schema.type === 'boolean' && str !== 'true' && str !== 'false') return key; + if (schema.type === 'int' && !/^-?\d+$/.test(str)) return key; + } + return null; +} + +export function createPropertiesRouter({ propertiesService }) { + const router = Router(); + + router.get('/', async (req, res, next) => { + try { + let current = {}; + try { + current = await propertiesService.read(); + } catch (err) { + if (err?.code !== 'ENOENT') throw err; // no file yet → serve defaults + } + res.json({ properties: withDefaults(current) }); + } catch (err) { next(err); } + }); + + router.put('/', async (req, res, next) => { + const properties = req.body?.properties || {}; + const bad = validate(properties); + if (bad) return res.status(400).json({ error: 'invalid_value', field: bad }); + try { + const merged = await propertiesService.update(properties); + res.json({ ok: true, properties: merged }); + } catch (err) { next(err); } + }); + + return router; +} diff --git a/src/routes/status.js b/src/routes/status.js new file mode 100644 index 0000000..b2d49b8 --- /dev/null +++ b/src/routes/status.js @@ -0,0 +1,40 @@ +import { Router } from 'express'; +import { ContainerNotFoundError } from '../services/dockerService.js'; + +export function createStatusRouter({ dockerService, appState }) { + const router = Router(); + + router.get('/', async (req, res, next) => { + try { + const info = await dockerService.inspect(); + let stats = { cpuPct: 0, memUsedMb: 0, memPct: 0 }; + let players = { online: 0, max: 0, players: [] }; + let edition = null; + if (info.state === 'running') { + stats = await dockerService.stats(); + edition = await appState.getEdition(); + try { + const adapter = await appState.getAdapter(); + players = await adapter.listPlayers(); + } catch { /* server up but not accepting commands yet */ } + } + res.json({ state: info.state, type: info.type, edition, ...stats, players }); + } catch (err) { next(err); } + }); + + for (const action of ['start', 'stop', 'restart', 'kill']) { + router.post(`/${action}`, async (req, res) => { + try { + await dockerService[action](); + res.json({ ok: true }); + } catch (err) { + if (err instanceof ContainerNotFoundError) { + return res.status(404).json({ error: 'not_found' }); + } + res.status(500).json({ error: err.message }); + } + }); + } + + return router; +} diff --git a/src/routes/world.js b/src/routes/world.js new file mode 100644 index 0000000..9e702a9 --- /dev/null +++ b/src/routes/world.js @@ -0,0 +1,32 @@ +import { Router } from 'express'; + +export function createWorldRouter({ worldService, upload }) { + const router = Router(); + + router.post('/regen', async (req, res, next) => { + const { seed } = req.body || {}; + try { res.json(await worldService.regen(seed)); } catch (err) { next(err); } + }); + + router.get('/download', async (req, res, next) => { + try { + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', 'attachment; filename="world.zip"'); + const archive = await worldService.createDownloadStream(); + archive.on('error', () => res.destroy()); + archive.pipe(res); + } catch (err) { next(err); } + }); + + router.post('/upload', upload.single('world'), async (req, res) => { + if (!req.file) return res.status(400).json({ error: 'no_file' }); + try { + await worldService.importWorld(req.file.path); + res.json({ ok: true }); + } catch { + res.status(400).json({ error: 'invalid_world' }); + } + }); + + return router; +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..7234e5a --- /dev/null +++ b/src/server.js @@ -0,0 +1,144 @@ +import express from 'express'; +import session from 'express-session'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Server as IOServer } from 'socket.io'; + +import { requireAuth } from './middleware/auth.js'; +import { createAuthRouter } from './routes/auth.js'; +import { createStatusRouter } from './routes/status.js'; +import { createPlayersRouter } from './routes/players.js'; +import { createPropertiesRouter } from './routes/properties.js'; +import { createWorldRouter } from './routes/world.js'; +import { registerSockets } from './sockets/index.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export function createApp(deps) { + const { config, dockerService, appState, propertiesService, worldService, authService, upload, seedService, logger } = deps; + const app = express(); + + // Comprehensive request logging (info level). + if (logger) { + app.use((req, res, next) => { + const start = Date.now(); + const ip = req.headers['x-forwarded-for'] || req.socket?.remoteAddress || 'unknown'; + const isHealth = req.originalUrl === '/api/health'; + const logLevel = isHealth ? 'debug' : 'info'; + + logger[logLevel](`--> ${req.method} ${req.originalUrl}`, { ip, userAgent: req.headers['user-agent'] }); + + let logged = false; + const logFinish = (status, extra = {}) => { + if (logged) return; + logged = true; + const durationMs = Date.now() - start; + const finishLevel = isHealth ? 'debug' : (status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info'); + logger[finishLevel](`<-- ${req.method} ${req.originalUrl} ${status} - ${durationMs}ms`, { + method: req.method, + url: req.originalUrl, + status, + durationMs, + ...extra, + }); + }; + + res.on('finish', () => { + logFinish(res.statusCode); + }); + + res.on('close', () => { + if (!res.writableEnded) { + logFinish(499, { aborted: true, note: 'Client/Gateway closed connection before response finished' }); + } + }); + + next(); + }); + } + + // Fast, un-intercepted health check endpoint for container probes + app.get('/api/health', (req, res) => res.json({ status: 'ok' })); + + + + // Behind a TLS-terminating reverse proxy (e.g. Coolify/Traefik on 443), the + // app receives plain http with X-Forwarded-Proto: https. Trusting the proxy + // lets express-session recognize the request as secure and actually set the + // `secure` session cookie — without this, login silently fails in production. + app.set('trust proxy', 1); + + const sessionMiddleware = session({ + secret: config.sessionSecret, + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + sameSite: 'lax', + secure: config.nodeEnv === 'production', + }, + }); + + app.use(express.json()); + app.use(sessionMiddleware); + + // public + app.use('/api/auth', createAuthRouter(authService)); + + + // guarded + app.use('/api', requireAuth); + app.use('/api/status', createStatusRouter({ dockerService, appState })); + app.use('/api/players', createPlayersRouter({ appState, propertiesService, config, dockerService })); + app.use('/api/properties', createPropertiesRouter({ propertiesService })); + app.use('/api/world', createWorldRouter({ worldService, upload })); + + // config exposure for the SPA (no secrets): map settings + resolved world seed + app.get('/api/client-config', requireAuth, async (req, res) => { + let seed = null; + let edition = null; + try { + const adapter = await appState.getAdapter(); + edition = adapter._edition || null; + seed = await seedService.resolve(adapter, edition); + } catch (err) { + logger?.warn('client-config: seed resolution failed', { error: err.message }); + } + res.json({ + mapVersion: config.mapVersion || '', + seed, + edition, + }); + }); + + // static SPA + app.use(express.static(path.join(__dirname, 'public'))); + + // error handler + // eslint-disable-next-line no-unused-vars + app.use((err, req, res, next) => { + logger?.error('unhandled request error', { url: req.originalUrl, error: err.message, stack: err.stack }); + res.status(500).json({ error: err.message }); + }); + + return { app, sessionMiddleware }; +} + +export function startServer(deps) { + const { app, sessionMiddleware } = createApp(deps); + const server = http.createServer(app); + const io = new IOServer(server); + registerSockets(io, { + dockerService: deps.dockerService, + appState: deps.appState, + seedService: deps.seedService, + sessionMiddleware, + logger: deps.logger, + config: deps.config, + }); + server.listen(deps.config.port, () => { + (deps.logger?.info ? deps.logger.info(`listening on :${deps.config.port}`) : console.log(`CraftDock listening on :${deps.config.port}`)); + }); + return server; +} diff --git a/src/services/authService.js b/src/services/authService.js new file mode 100644 index 0000000..61b7f41 --- /dev/null +++ b/src/services/authService.js @@ -0,0 +1,10 @@ +import bcrypt from 'bcryptjs'; + +export function createAuthService(config) { + return { + async verifyPassword(plain) { + if (!plain) return false; + return bcrypt.compare(plain, config.adminPasswordHash); + }, + }; +} diff --git a/src/services/bedrockIdentityBridge.js b/src/services/bedrockIdentityBridge.js new file mode 100644 index 0000000..b847dc7 --- /dev/null +++ b/src/services/bedrockIdentityBridge.js @@ -0,0 +1,62 @@ +import fsp from 'node:fs/promises'; +import path from 'node:path'; + +// Persistent bridge between a Bedrock player's identity (numeric XUID + gamertag, +// known only from the log directory) and their LevelDB record (keyed by a +// self-signed UUID that never references the XUID). Learned while a player is +// online by matching the live entity `uniqueId` (from querytarget) to a LevelDB +// player's `UniqueID`; persisted so offline reads work forever afterwards. +// +// File: /craftdock_bedrock_ids.json +// { "": { name, leveldbUuid, uniqueId, boundAt } } + +const FILE = 'craftdock_bedrock_ids.json'; + +export function createBedrockIdentityBridge({ dataRoot, fileAdapter }) { + const filePath = path.join(dataRoot, FILE); + + async function readMap() { + try { + const data = JSON.parse(await fsp.readFile(filePath, 'utf8')); + return data && typeof data === 'object' && !Array.isArray(data) ? data : {}; + } catch { + return {}; + } + } + + async function writeMap(map) { + try { + await fsp.writeFile(filePath, JSON.stringify(map, null, 2), 'utf8'); + } catch { /* best-effort */ } + } + + // Bind xuid → LevelDB uuid using the live uniqueId or fallbackUuid. + async function learn({ xuid, name, uniqueId, fallbackUuid }) { + if (!xuid) return null; + let targetUuid = fallbackUuid; + if (!targetUuid && uniqueId != null) { + const hit = await fileAdapter.findByUniqueId(String(uniqueId)); + if (hit) targetUuid = hit.uuid; + } + if (!targetUuid) return null; + + const map = await readMap(); + const binding = { + name: name ?? map[xuid]?.name ?? null, + leveldbUuid: targetUuid, + uniqueId: uniqueId ? String(uniqueId) : (map[xuid]?.uniqueId || null), + boundAt: new Date().toISOString(), + }; + map[xuid] = binding; + await writeMap(map); + return binding; + } + + + async function resolveLeveldbUuid(xuid) { + const map = await readMap(); + return map[xuid]?.leveldbUuid || null; + } + + return { learn, resolveLeveldbUuid }; +} diff --git a/src/services/dockerService.js b/src/services/dockerService.js new file mode 100644 index 0000000..3e9d3b0 --- /dev/null +++ b/src/services/dockerService.js @@ -0,0 +1,172 @@ +import os from 'node:os'; + +export class ContainerNotFoundError extends Error { + constructor(name) { + super(`Container not found: ${name}`); + this.name = 'ContainerNotFoundError'; + } +} + +function parseType(env = []) { + const entry = env.find((e) => e.startsWith('TYPE=')); + return entry ? entry.slice('TYPE='.length) : null; +} + +const SERVICE_LABEL = 'com.docker.compose.service'; +const PROJECT_LABEL = 'com.docker.compose.project'; + +function withTimeout(promise, ms, label, logger) { + let timer; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + const err = new Error(`Docker API call timed out (${ms}ms): ${label}`); + logger?.error('docker API timeout', { label, timeoutMs: ms }); + reject(err); + }, ms); + }); + return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer)); +} + +export function createDockerService(config, docker, logger) { + const name = config.mcContainerName; + const serviceName = config.mcServiceName; + let ownProjectCache; + let statsCache = null; + let lastStatsTime = 0; + let inFlightStatsPromise = null; + + // The panel's own compose project, read by self-inspecting the panel container + // (its hostname is its container id). Used to scope the service-label fallback + // so we never pick up a same-named service from another project. + async function ownProject() { + if (ownProjectCache !== undefined) return ownProjectCache; + try { + const self = await withTimeout(docker.getContainer(os.hostname()).inspect(), 3000, 'selfInspect', logger); + ownProjectCache = self.Config?.Labels?.[PROJECT_LABEL] || null; + } catch (err) { + logger?.debug('ownProject inspect failed/skipped', { error: err.message }); + ownProjectCache = null; + } + return ownProjectCache; + } + + async function resolve() { + let list; + try { + list = await withTimeout(docker.listContainers({ all: true }), 5000, 'listContainers', logger); + } catch (err) { + logger?.error('docker listContainers failed', { error: err.message }); + throw err; + } + + // 1. Exact container name (works locally where container_name is honored). + let match = list.find((c) => c.Names?.some((n) => n === `/${name}` || n === name)); + + // 2. Fallback: compose service label (handles Coolify's auto-generated names), + // scoped to the panel's own project when it can be determined. + if (!match && serviceName) { + const project = await ownProject(); + match = list.find((c) => + c.Labels?.[SERVICE_LABEL] === serviceName && + (!project || c.Labels?.[PROJECT_LABEL] === project)); + } + + if (!match) throw new ContainerNotFoundError(name); + return docker.getContainer(match.Id); + } + + async function lifecycle(method) { + logger?.info(`docker container lifecycle: ${method}`, { name }); + const container = await resolve(); + await withTimeout(container[method](), 10000, `lifecycle:${method}`, logger); + } + + async function inspect() { + let container; + try { + container = await resolve(); + } catch (err) { + if (err instanceof ContainerNotFoundError) { + return { found: false, state: 'not_found', type: null }; + } + logger?.warn('docker inspect container resolve error', { error: err.message }); + return { found: false, state: 'error', type: null }; + } + try { + const data = await withTimeout(container.inspect(), 5000, 'inspect', logger); + return { + found: true, + state: data.State?.Status || 'unknown', + type: parseType(data.Config?.Env), + }; + } catch (err) { + logger?.warn('docker container inspect error', { error: err.message }); + return { found: true, state: 'unknown', type: null }; + } + } + + + return { + async getContainer() { return resolve(); }, + start: () => lifecycle('start'), + stop: () => lifecycle('stop'), + restart: () => lifecycle('restart'), + kill: () => lifecycle('kill'), + inspect, + + async getState() { + return (await inspect()).state; + }, + + async stats() { + const now = Date.now(); + if (statsCache && (now - lastStatsTime < 2000)) { + return statsCache; + } + if (inFlightStatsPromise) { + return inFlightStatsPromise; + } + + inFlightStatsPromise = (async () => { + try { + const container = await resolve(); + const s = await container.stats({ stream: false }); + const cpuDelta = + (s.cpu_stats?.cpu_usage?.total_usage ?? 0) - + (s.precpu_stats?.cpu_usage?.total_usage ?? 0); + const sysDelta = + (s.cpu_stats?.system_cpu_usage ?? 0) - (s.precpu_stats?.system_cpu_usage ?? 0); + const cpus = s.cpu_stats?.online_cpus || 1; + const cpuPct = sysDelta > 0 ? (cpuDelta / sysDelta) * cpus * 100 : 0; + const memUsed = s.memory_stats?.usage ?? 0; + const memLimit = s.memory_stats?.limit || 1; + + statsCache = { + cpuPct: Math.round(cpuPct * 10) / 10, + memUsedMb: Math.round(memUsed / (1024 * 1024)), + memPct: Math.round((memUsed / memLimit) * 1000) / 10, + }; + lastStatsTime = Date.now(); + return statsCache; + } catch { + return statsCache || { cpuPct: 0, memUsedMb: 0, memPct: 0 }; + } finally { + inFlightStatsPromise = null; + } + })(); + + return inFlightStatsPromise; + }, + + + async logStream(tail = 200) { + const container = await resolve(); + return container.logs({ follow: true, stdout: true, stderr: true, tail }); + }, + + async attach() { + const container = await resolve(); + return container.attach({ stream: true, stdin: true, stdout: true, stderr: true }); + }, + }; +} diff --git a/src/services/firstBootService.js b/src/services/firstBootService.js new file mode 100644 index 0000000..7365378 --- /dev/null +++ b/src/services/firstBootService.js @@ -0,0 +1,33 @@ +import fsp from 'node:fs/promises'; +import path from 'node:path'; + +// One-time defaults applied the FIRST time CraftDock sees a data volume. +// Currently: force the whitelist off, so a brand-new server always starts open. +// A marker file at the data root (sibling of the other craftdock_* metadata) +// records that initialization happened — plain restarts never touch anything, +// preserving whatever the admin toggled afterwards. + +export const MARKER_FILE = 'craftdock_initialized.json'; + +const WHITELIST_KEYS = { bedrock: ['allow-list'], java: ['white-list'] }; + +export async function ensureFirstBootDefaults({ config, propertiesService, logger }) { + const marker = path.join(config.mcDataPath, MARKER_FILE); + try { + await fsp.access(marker); + return; // not the first boot + } catch { /* marker absent → first boot */ } + + try { + const keys = WHITELIST_KEYS[config.mcEdition] || [...WHITELIST_KEYS.bedrock, ...WHITELIST_KEYS.java]; + const patch = Object.fromEntries(keys.map((k) => [k, 'false'])); + await propertiesService.update(patch); + + await fsp.writeFile(marker, JSON.stringify({ initializedAt: new Date().toISOString(), applied: patch }, null, 2), 'utf8'); + await fsp.chmod(marker, 0o666).catch(() => {}); + logger?.info('first boot: whitelist disabled by default', { keys }); + } catch (err) { + // Best-effort: log and leave the marker unwritten so the next boot retries. + logger?.warn('first-boot defaults failed', { error: err?.message }); + } +} diff --git a/src/services/playerDetailService.js b/src/services/playerDetailService.js new file mode 100644 index 0000000..2e47c20 --- /dev/null +++ b/src/services/playerDetailService.js @@ -0,0 +1,98 @@ +// Orchestrates a fresh, safe read of one player's data from the world save. +// +// Flow (getDetail(xuid)): +// 1. Resolve the gamertag from the directory (xuid is the canonical key; for +// Java the key is the player's UUID). +// 2. Determine online (listPlayers) and whether the server is running. +// 3. Bedrock: while online, learn the identity bridge (querytarget uniqueId → +// LevelDB UniqueID); resolve the LevelDB uuid. If never bridged → needsBridge. +// Read under a `save hold` snapshot when the server is running. +// 4. Java: force `save-all flush` when the target is online, then read the .dat. +// +// Collaborators are injected so the whole flow is unit-testable. + +const SUPPORTED = { health: true, food: true, inventory: true }; + +async function withSnapshot(adapter, fn) { + const held = await adapter.saveHold(); + try { + return await fn(); + } finally { + if (held) { + await adapter.saveResume(); + } + } +} + + +export function createPlayerDetailService({ + config, appState, dockerService, readDirectory, createFileAdapter, createBridge, +}) { + const dataRoot = config.mcDataPath; + + async function getDetail(xuid) { + const dir = await readDirectory(dataRoot); + const entry = dir[xuid]; + if (!entry) return null; + const name = entry.name; + + const adapter = await appState.getAdapter(); + const edition = adapter._edition; + const list = await adapter.listPlayers(); + const online = Array.isArray(list?.players) && list.players.includes(name); + const serverRunning = (await dockerService.inspect()).state === 'running'; + const fileAdapter = createFileAdapter(edition, config); + + let data; + if (edition === 'bedrock') { + const bridge = createBridge({ dataRoot, fileAdapter }); + + // Bridge lookup → live querytarget learn (online only) → single-player + // world fallback. Identical whether or not we're inside a snapshot. + const resolveUuid = async () => { + let uuid = await bridge.resolveLeveldbUuid(xuid); + if (!uuid && online) { + const uniqueId = await adapter.queryUniqueId(name); + if (uniqueId) await bridge.learn({ xuid, name, uniqueId }); + uuid = await bridge.resolveLeveldbUuid(xuid); + } + if (!uuid && typeof fileAdapter.listServerUuids === 'function') { + try { + const uuids = await fileAdapter.listServerUuids(); + if (Array.isArray(uuids) && uuids.length === 1) { + uuid = uuids[0]; + await bridge.learn({ xuid, name, fallbackUuid: uuid }); + } + } catch { /* ignore fallback error */ } + } + return uuid; + }; + + const readViaUuid = async () => { + // Resolve inside the snapshot (when held) to avoid LevelDB lock conflicts. + const leveldbUuid = await resolveUuid(); + return leveldbUuid ? { leveldbUuid, data: await fileAdapter.readPlayer(leveldbUuid) } : null; + }; + + const result = serverRunning + ? await withSnapshot(adapter, readViaUuid) + : await readViaUuid(); + + if (!result) { + return { xuid, name, online, needsBridge: true, supported: SUPPORTED }; + } + data = result.data; + } else { + const uuid = entry.xuid || xuid; // Java directory key is the player UUID + if (online && serverRunning) await adapter.forceSave(); + data = await fileAdapter.readPlayer(uuid); + } + + if (!data) { + return { xuid, name, online, needsBridge: false, empty: true, position: null, supported: SUPPORTED }; + } + return { xuid, name, online, needsBridge: false, supported: SUPPORTED, ...data }; + } + + return { getDetail }; +} diff --git a/src/services/playerDirectory.js b/src/services/playerDirectory.js new file mode 100644 index 0000000..85c3c53 --- /dev/null +++ b/src/services/playerDirectory.js @@ -0,0 +1,208 @@ +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { PLAYER_NAME_REGEX } from '../adapters/serverAdapter.js'; + +// A persistent XUID→name directory of players who REALLY accessed the world. +// +// Why not the world files directly? A Bedrock world is a compressed LevelDB that +// stores player data keyed by XUID with NO readable gamertag — the name only ever +// appears in the server log, at login. So we build the mapping from the log, but +// keyed by XUID (the canonical identity: survives renames, never duplicates) and +// persisted here so it outlives log rotation. +// +// "Really accessed" = a `Player Spawned` line. A player rejected by the allowlist +// gets a `Player connected` line but is disconnected BEFORE spawning, so keying on +// spawn is what excludes them (Scenario 1). Because entries are persisted, players +// who joined while the server was public stay listed even after an allowlist is +// later enabled and they're no longer on it (Scenario 2). +// +// File lives at the data root (sibling of allowlist.json) so it's bundled into the +// world export/backup, like the rest of CraftDock's metadata. + +const FILE = 'craftdock_players.json'; +const LEGACY_HISTORY = 'craftdock_players_history.json'; + +// Bedrock spawn line, e.g. +// [2026-07-19 02:29:10:123 INFO] Player Spawned: Lucas Guerra xuid: 2535407895138987, pfid: ... +// Gamertags may contain spaces, so capture non-greedily up to " xuid:". +const SPAWN_RE = /Player Spawned:\s*(.+?)\s+xuid:\s*(\d+)/gi; + +// Control chars docker interleaves as stream-framing bytes; strip them but keep +// newlines so log lines stay separated. Built from char codes to avoid embedding +// raw control bytes in source. +const CTRL_RE = new RegExp('[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F-\\x9F]', 'g'); + +// Module-level cursor (single server instance): Unix seconds of the last log scan. +// First scan reads a bounded tail; later scans read only new lines via `since`, so +// the 2s status poll never re-parses the whole (ever-growing) container log. +let lastScanSec = 0; + +function filePath(dataRoot) { + return path.join(dataRoot, FILE); +} + +export async function readDirectory(dataRoot) { + try { + const raw = await fsp.readFile(filePath(dataRoot), 'utf8'); + const data = JSON.parse(raw); + return data && typeof data === 'object' && !Array.isArray(data) ? data : {}; + } catch { + return {}; + } +} + +async function writeDirectory(dataRoot, dir) { + try { + const file = filePath(dataRoot); + await fsp.writeFile(file, JSON.stringify(dir, null, 2), 'utf8'); + await fsp.chmod(file, 0o666).catch(() => {}); + } catch { /* best-effort: a failed write just means we re-scan next tick */ } +} + +// One-time import of the old name-only history array. Those entries have no XUID, +// so they're keyed `legacy:` and superseded the first time the same name is +// seen spawning with a real XUID (see mergeSpawn). +async function migrateLegacy(dataRoot, dir) { + const legacyPath = path.join(dataRoot, LEGACY_HISTORY); + let names; + try { + names = JSON.parse(await fsp.readFile(legacyPath, 'utf8')); + } catch { + return false; + } + if (!Array.isArray(names)) { await fsp.rm(legacyPath, { force: true }).catch(() => {}); return false; } + + const known = new Set(Object.values(dir).map((e) => e.name)); + for (const name of names) { + if (typeof name === 'string' && PLAYER_NAME_REGEX.test(name) && !known.has(name)) { + dir[`legacy:${name}`] = { name, xuid: null, firstSeen: null, lastSeen: null }; + known.add(name); + } + } + await fsp.rm(legacyPath, { force: true }).catch(() => {}); + return true; +} + +// Merge one spawn (xuid + current name) into the directory. Returns true if the +// directory changed. Promotes any matching `legacy:` placeholder to the real +// XUID-keyed entry. +function mergeSpawn(dir, xuid, name, nowIso) { + if (!/^\d+$/.test(xuid) || !PLAYER_NAME_REGEX.test(name)) return false; + + const legacyKey = `legacy:${name}`; + const hadLegacy = legacyKey in dir; + if (hadLegacy) delete dir[legacyKey]; + + const existing = dir[xuid]; + if (!existing) { + dir[xuid] = { name, xuid, firstSeen: nowIso, lastSeen: nowIso }; + return true; + } + // Same identity: refresh the name (rename) and lastSeen. + const renamed = existing.name !== name; + existing.name = name; + existing.lastSeen = nowIso; + if (!existing.xuid) existing.xuid = xuid; + return renamed || hadLegacy; +} + +let lastDirectoryUpdateMs = 0; +let cachedDirectoryList = null; + +async function scanSpawns(dockerService, sinceSec, logger) { + const spawns = []; + try { + const container = await dockerService.getContainer(); + const opts = { stdout: true, stderr: true, follow: false }; + if (sinceSec) opts.since = sinceSec; + else opts.tail = 2000; + const buf = await container.logs(opts); + const text = buf.toString('utf8').replace(CTRL_RE, ''); + let m; + while ((m = SPAWN_RE.exec(text)) !== null) { + spawns.push({ name: m[1].trim(), xuid: m[2] }); + } + SPAWN_RE.lastIndex = 0; + } catch (err) { + logger?.warn('player directory scanSpawns failed', { error: err?.message }); + } + return spawns; +} + +// For Java, the world already carries name↔uuid in usercache.json (real world +// data), so seed the directory from it — no log scanning needed. +async function seedFromUsercache(dataRoot, dir, nowIso) { + let changed = false; + try { + const cache = JSON.parse(await fsp.readFile(path.join(dataRoot, 'usercache.json'), 'utf8')); + if (Array.isArray(cache)) { + for (const e of cache) { + if (e?.name && e?.uuid && PLAYER_NAME_REGEX.test(e.name) && !dir[e.uuid]) { + dir[e.uuid] = { name: e.name, xuid: e.uuid, firstSeen: nowIso, lastSeen: nowIso }; + changed = true; + } + } + } + } catch { /* no usercache (Bedrock, or none yet) */ } + return changed; +} + +/** + * Update and return the player directory as a flat, sorted list. + * @returns {Promise>} + */ +export async function updatePlayerDirectory({ dataRoot, dockerService, edition, logger, minIntervalMs = 0 }) { + const now = Date.now(); + if (minIntervalMs > 0 && cachedDirectoryList && (now - lastDirectoryUpdateMs < minIntervalMs)) { + return cachedDirectoryList; + } + + const dir = await readDirectory(dataRoot); + const nowIso = new Date(now).toISOString(); + let changed = await migrateLegacy(dataRoot, dir); + + if (edition === 'java') { + if (await seedFromUsercache(dataRoot, dir, nowIso)) changed = true; + } else if (dockerService) { + // Capture the cursor BEFORE scanning so lines written during the scan aren't + // lost on the next pass. + const nowSec = Math.floor(now / 1000); + const spawns = await scanSpawns(dockerService, lastScanSec || undefined, logger); + lastScanSec = nowSec; + for (const s of spawns) { + if (mergeSpawn(dir, s.xuid, s.name, nowIso)) changed = true; + } + } + + if (changed) await writeDirectory(dataRoot, dir); + cachedDirectoryList = Object.values(dir).sort((a, b) => a.name.localeCompare(b.name)); + lastDirectoryUpdateMs = Date.now(); + return cachedDirectoryList; +} + + +/** Resolve an XUID to its current gamertag (Bedrock console commands are name-based). */ +export async function resolveName(dataRoot, xuid) { + const dir = await readDirectory(dataRoot); + return dir[xuid]?.name || null; +} + +/** + * Server-side paginated + filtered view of the directory. Scales to very large + * directories: the client fetches one page at a time instead of the whole list. + * @returns {Promise<{ items: Array, total: number, page: number, pageSize: number }>} + */ +export async function queryDirectory(dataRoot, { page = 1, pageSize = 25, q = '' } = {}) { + const dir = await readDirectory(dataRoot); + const needle = String(q || '').trim().toLowerCase(); + let items = Object.values(dir); + if (needle) items = items.filter((e) => e.name?.toLowerCase().includes(needle)); + items.sort((a, b) => a.name.localeCompare(b.name)); + + const total = items.length; + const size = Math.max(1, pageSize | 0); + const pageCount = Math.max(1, Math.ceil(total / size)); + const current = Math.min(Math.max(1, page | 0), pageCount); + const start = (current - 1) * size; + return { items: items.slice(start, start + size), total, page: current, pageSize: size }; +} diff --git a/src/services/propertiesService.js b/src/services/propertiesService.js new file mode 100644 index 0000000..5506d75 --- /dev/null +++ b/src/services/propertiesService.js @@ -0,0 +1,53 @@ +import fsp from 'node:fs/promises'; +import path from 'node:path'; + +export function createPropertiesService(config, fs = fsp) { + const file = path.join(config.mcDataPath, 'server.properties'); + + function parse(text) { + const obj = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx === -1) continue; + obj[trimmed.slice(0, idx)] = trimmed.slice(idx + 1); + } + return obj; + } + + function serialize(obj, originalText = '') { + const seen = new Set(); + const lines = originalText.split(/\r?\n/).map((line) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) return line; + const idx = trimmed.indexOf('='); + if (idx === -1) return line; + const key = trimmed.slice(0, idx); + if (!(key in obj)) return line; + seen.add(key); + return `${key}=${obj[key]}`; + }); + for (const [key, value] of Object.entries(obj)) { + if (!seen.has(key)) lines.push(`${key}=${value}`); + } + return lines.join('\n'); + } + + async function read() { + return parse(await fs.readFile(file, 'utf8')); + } + + async function update(patch) { + let original = ''; + try { original = await fs.readFile(file, 'utf8'); } catch { original = ''; } + const merged = { ...parse(original), ...patch }; + await fs.writeFile(file, serialize(merged, original), 'utf8'); + if (typeof fs.chmod === 'function') { + await fs.chmod(file, 0o666).catch(() => {}); + } + return merged; + } + + return { parse, serialize, read, update }; +} diff --git a/src/services/rconService.js b/src/services/rconService.js new file mode 100644 index 0000000..591e8c5 --- /dev/null +++ b/src/services/rconService.js @@ -0,0 +1,50 @@ +import { Rcon } from 'rcon-client'; + +export class RconUnavailableError extends Error { + constructor(cause) { + super(`RCON unavailable: ${cause?.message || cause}`); + this.name = 'RconUnavailableError'; + } +} + +export function createRconService(config, RconClass = Rcon, logger) { + let conn = null; + + async function ensure() { + if (conn) return conn; + try { + conn = await RconClass.connect({ + host: config.rconHost, + port: config.rconPort, + password: config.rconPassword, + }); + logger?.info('rcon connected', { host: config.rconHost, port: config.rconPort }); + conn.on?.('error', () => { conn = null; }); + conn.on?.('end', () => { conn = null; }); + return conn; + } catch (err) { + conn = null; + logger?.warn('rcon connect failed', { error: err.message }); + throw new RconUnavailableError(err); + } + } + + return { + async send(cmd) { + const c = await ensure(); + logger?.debug('rcon >', { cmd }); + try { + const out = await c.send(cmd); + logger?.debug('rcon <', { cmd, out }); + return out; + } catch (err) { + conn = null; + logger?.warn('rcon send failed', { cmd, error: err.message }); + throw new RconUnavailableError(err); + } + }, + async close() { + if (conn) { await conn.end?.(); conn = null; } + }, + }; +} diff --git a/src/services/seedService.js b/src/services/seedService.js new file mode 100644 index 0000000..5edf304 --- /dev/null +++ b/src/services/seedService.js @@ -0,0 +1,93 @@ +import fsp from 'node:fs/promises'; +import path from 'node:path'; + +// MC data paths are always container (POSIX) paths, regardless of the host OS. +const posixJoin = path.posix.join; + +// Bedrock stores the world seed in worlds//level.dat as a +// little-endian NBT Long named "RandomSeed" (there is NO `seed` console command +// on Bedrock — that is Java-only). The value bytes follow the tag name directly. +export function parseBedrockSeed(buffer) { + const marker = Buffer.from('RandomSeed', 'utf8'); + const idx = buffer.indexOf(marker); + if (idx === -1) return null; + const valueOffset = idx + marker.length; + if (valueOffset + 8 > buffer.length) return null; + return buffer.readBigInt64LE(valueOffset).toString(); +} + +export function createSeedService({ config, propertiesService, fs = fsp, logger } = {}) { + let cachedSeed = null; + + async function fromProperties() { + try { + const props = await propertiesService.read(); + const raw = props['level-seed']; + if (raw != null && String(raw).trim() !== '') return String(raw).trim(); + } catch (err) { + logger?.debug('seed: could not read server.properties', err); + } + return null; + } + + async function bedrockLevelName() { + try { + const props = await propertiesService.read(); + if (props['level-name']) return props['level-name']; + } catch { /* fall through to default */ } + return 'Bedrock level'; + } + + async function fromBedrockLevelDat() { + const levelName = await bedrockLevelName(); + const datPath = posixJoin(config.mcDataPath, 'worlds', levelName, 'level.dat'); + try { + const seed = parseBedrockSeed(await fs.readFile(datPath)); + if (!seed) logger?.warn('seed: RandomSeed not found in level.dat', { datPath }); + return seed; + } catch (err) { + logger?.warn('seed: could not read bedrock level.dat', { datPath, error: err.message }); + return null; + } + } + + async function fromJavaCommand(adapter) { + try { + const seed = adapter?.getSeed ? await adapter.getSeed() : null; + if (!seed) logger?.warn('seed: java command returned nothing'); + return seed; + } catch (err) { + logger?.warn('seed: java seed command failed', err); + return null; + } + } + + // Resolves the world seed for the given edition. `level-seed` (a fixed seed set + // by the operator) always wins; otherwise Bedrock reads level.dat and Java asks + // the server over RCON. + async function resolve(adapter, edition) { + if (cachedSeed !== null) { + return cachedSeed; + } + const fromProps = await fromProperties(); + if (fromProps) { + logger?.debug('seed resolved from server.properties', { seed: fromProps }); + cachedSeed = fromProps; + return fromProps; + } + const seed = edition === 'bedrock' + ? await fromBedrockLevelDat() + : await fromJavaCommand(adapter); + logger?.info('seed resolved', { edition, seed }); + if (seed !== null) { + cachedSeed = seed; + } + return seed; + } + + function clearCache() { + cachedSeed = null; + } + + return { resolve, fromProperties, fromBedrockLevelDat, fromJavaCommand, clearCache }; +} diff --git a/src/services/stdinService.js b/src/services/stdinService.js new file mode 100644 index 0000000..e3051c8 --- /dev/null +++ b/src/services/stdinService.js @@ -0,0 +1,115 @@ +export function createStdinService(dockerService, { windowMs = 300, timeoutMs = 3000, logger } = {}) { + let stream = null; + // Mutex: each send() chains on the previous one so only one command is in + // flight at a time. This prevents response interleaving on the shared + // Docker attach stream (the root cause of Bedrock command corruption). + let tail = Promise.resolve(); + + async function ensure() { + if (stream && !stream.destroyed) return stream; + + // Timeout for attach call in case Docker API stalls + let attachTimer; + const attachPromise = dockerService.attach(); + const timeoutPromise = new Promise((_, reject) => { + attachTimer = setTimeout(() => reject(new Error('docker attach timeout')), timeoutMs); + }); + + try { + const s = await Promise.race([attachPromise, timeoutPromise]); + clearTimeout(attachTimer); + stream = s; + // When the container stops/restarts (e.g. during a world import) the attach + // stream ends but is not always flagged `.destroyed`. Invalidate the cache on + // end/close/error so the next command re-attaches to the fresh process + // instead of writing into a dead stream (which left `list` returning nothing). + const invalidate = () => { if (stream === s) stream = null; }; + s.once('end', invalidate); + s.once('close', invalidate); + s.once('error', invalidate); + return s; + } catch (err) { + clearTimeout(attachTimer); + stream = null; + throw err; + } + } + + function exec(cmd) { + return new Promise((resolve, reject) => { + let s = null; + let safetyTimer = null; + let windowTimer = null; + let onData = null; + let isDone = false; + + const cleanup = () => { + if (safetyTimer) clearTimeout(safetyTimer); + if (windowTimer) clearTimeout(windowTimer); + if (s && onData) { + try { s.off('data', onData); } catch {} + } + }; + + // Safety timeout for the entire command execution + safetyTimer = setTimeout(() => { + if (isDone) return; + isDone = true; + logger?.warn('stdin command timed out', { cmd, timeoutMs }); + cleanup(); + if (stream === s) stream = null; + if (s && !s.destroyed) { + try { s.destroy(); } catch {} + } + reject(new Error(`Command timed out after ${timeoutMs}ms: ${cmd}`)); + }, timeoutMs); + + ensure() + .then((attachedStream) => { + if (isDone) return; + s = attachedStream; + logger?.debug('stdin >', { cmd }); + let buf = ''; + onData = (chunk) => { buf += chunk.toString(); }; + s.on('data', onData); + s.write(cmd + '\n', (err) => { + if (err) { + if (isDone) return; + isDone = true; + logger?.warn('stdin write error', { cmd, error: err.message }); + cleanup(); + if (stream === s) stream = null; + return reject(err); + } + }); + + windowTimer = setTimeout(() => { + if (isDone) return; + isDone = true; + cleanup(); + const out = buf.trim(); + logger?.debug('stdin <', { cmd, out }); + resolve(out); + }, windowMs); + }) + .catch((err) => { + if (isDone) return; + isDone = true; + logger?.warn('stdin ensure failed', { cmd, error: err.message }); + cleanup(); + if (stream === s) stream = null; + reject(err); + }); + }); + } + + return { + send(cmd) { + const p = tail.then(() => exec(cmd)); + // Always advance the tail, even on failure, so the queue never stalls + tail = p.catch(() => {}); + return p; + }, + }; +} + diff --git a/src/services/worldService.js b/src/services/worldService.js new file mode 100644 index 0000000..5496479 --- /dev/null +++ b/src/services/worldService.js @@ -0,0 +1,197 @@ +import fsp from 'node:fs/promises'; +import path from 'node:path'; + +// Player-data files kept at the data root (outside the world folder). Bundling +// them into the export makes a world backup portable between instances — Bedrock +// stores no readable player-name list in the world itself, so allowlist/history +// are the only way authorized/known players survive a migration. +const PLAYER_DATA_FILES = [ + 'allowlist.json', 'permissions.json', // Bedrock + 'whitelist.json', 'ops.json', 'usercache.json', // Java + 'banned-players.json', 'banned-ips.json', // Java bans + 'craftdock_players_history.json', // CraftDock join history +]; +const AUX_DIR = 'craftdock'; + +export function createWorldService({ config, dockerService, propertiesService, fs = fsp, archiver, extractZip }) { + const worldPath = path.join(config.mcDataPath, config.mcWorldName); + + async function stopIfRunning() { + if ((await dockerService.getState()) === 'running') { + await dockerService.stop(); + return true; + } + return false; + } + + async function regen(seed) { + await stopIfRunning(); + if (seed !== undefined && propertiesService) { + await propertiesService.update({ 'level-seed': seed }); + } + await fs.rm(worldPath, { recursive: true, force: true }); + await dockerService.start(); + return { ok: true }; + } + + async function createDownloadStream() { + const archive = archiver('zip', { zlib: { level: 6 } }); + archive.directory(worldPath, config.mcWorldName); + // Bundle player-data files (allowlist/permissions/history/...) under craftdock/ + // so a downloaded world is a portable full backup. + for (const file of PLAYER_DATA_FILES) { + const full = path.join(config.mcDataPath, file); + try { + await fs.access(full); + archive.file(full, { name: `${AUX_DIR}/${file}` }); + } catch { /* file absent — skip */ } + } + archive.finalize(); + return archive; + } + + const importPath = worldPath + '.import'; + const auxTemp = importPath + '.aux'; + + async function findLevelDatDir(dir) { + const entries = await fs.readdir(dir); + if (entries.includes('level.dat')) { + return dir; + } + for (const entry of entries) { + const entryPath = path.join(dir, entry); + const stat = await fs.stat(entryPath); + if (stat.isDirectory()) { + const found = await findLevelDatDir(entryPath); + if (found) return found; + } + } + return null; + } + + async function importWorld(zipPath) { + const wasRunning = await stopIfRunning(); + let hasAux = false; + + try { + await fs.rm(importPath, { recursive: true, force: true }); + await fs.rm(auxTemp, { recursive: true, force: true }); + await fs.mkdir(importPath, { recursive: true }); + await extractZip(zipPath, importPath); + + // Rescue bundled player-data files (craftdock/) BEFORE world alignment, + // which deletes everything in importPath that is not the world. + const craftdockDir = path.join(importPath, AUX_DIR); + try { + await fs.access(craftdockDir); + await fs.rename(craftdockDir, auxTemp); + hasAux = true; + } catch { /* no bundled aux files (older export) */ } + + const levelDatDir = await findLevelDatDir(importPath); + if (!levelDatDir) { + throw new Error('invalid_world: level.dat not found'); + } + + // 2. Adjust paths based on Edition / World Name configuration + if (config.mcWorldName === 'worlds') { + // Bedrock: We need the structure to be importPath//level.dat + let levelName = 'Bedrock level'; + if (propertiesService) { + try { + const props = await propertiesService.read(); + levelName = props['level-name'] || 'Bedrock level'; + } catch {} + } + + const targetWorldDir = path.join(importPath, levelName); + + if (levelDatDir === importPath) { + // level.dat is in root. Move everything to a temp folder, then create and move there. + const tempDir = importPath + '.temp'; + await fs.mkdir(tempDir, { recursive: true }); + const files = await fs.readdir(importPath); + for (const file of files) { + await fs.rename(path.join(importPath, file), path.join(tempDir, file)); + } + await fs.mkdir(targetWorldDir, { recursive: true }); + const tempFiles = await fs.readdir(tempDir); + for (const file of tempFiles) { + await fs.rename(path.join(tempDir, file), path.join(targetWorldDir, file)); + } + await fs.rm(tempDir, { recursive: true, force: true }); + } else { + // level.dat is in a subdirectory (levelDatDir). Rename that subdirectory to . + const tempAligned = path.join(importPath, 'aligned_world'); + await fs.rename(levelDatDir, tempAligned); + + // Clean up any other files in importPath that are not tempAligned + const remaining = await fs.readdir(importPath); + for (const item of remaining) { + const itemPath = path.join(importPath, item); + if (itemPath !== tempAligned) { + await fs.rm(itemPath, { recursive: true, force: true }); + } + } + + await fs.rename(tempAligned, targetWorldDir); + } + } else { + // Java: We need the structure to be importPath/level.dat + if (levelDatDir !== importPath) { + const tempAligned = path.join(importPath, 'aligned_world'); + await fs.rename(levelDatDir, tempAligned); + + // Clean up any other files in importPath that are not tempAligned + const remaining = await fs.readdir(importPath); + for (const item of remaining) { + const itemPath = path.join(importPath, item); + if (itemPath !== tempAligned) { + await fs.rm(itemPath, { recursive: true, force: true }); + } + } + + // Hoist all files from aligned_world to importPath + const files = await fs.readdir(tempAligned); + for (const file of files) { + await fs.rename(path.join(tempAligned, file), path.join(importPath, file)); + } + await fs.rm(tempAligned, { recursive: true, force: true }); + } + } + + } catch (err) { + await fs.rm(importPath, { recursive: true, force: true }); + await fs.rm(auxTemp, { recursive: true, force: true }).catch(() => {}); + if (wasRunning) await dockerService.start(); + throw err; + } + await fs.rm(worldPath, { recursive: true, force: true }); + await fs.rename(importPath, worldPath); + + // Restore bundled player-data files to the data root (overwriting). + if (hasAux) { + const files = await fs.readdir(auxTemp); + for (const file of files) { + const dest = path.join(config.mcDataPath, file); + await fs.rename(path.join(auxTemp, file), dest); + if (typeof fs.chmod === 'function') { + await fs.chmod(dest, 0o666).catch(() => {}); + } + } + await fs.rm(auxTemp, { recursive: true, force: true }); + } + await dockerService.start(); + return { ok: true }; + } + + return { + regen, + createDownloadStream, + importWorld, + _worldPath: worldPath, + _stopIfRunning: stopIfRunning, + _archiver: archiver, + _extractZip: extractZip, + }; +} diff --git a/src/sockets/index.js b/src/sockets/index.js new file mode 100644 index 0000000..9347f66 --- /dev/null +++ b/src/sockets/index.js @@ -0,0 +1,17 @@ +import { registerLogsSocket } from './logsSocket.js'; +import { registerStatusSocket } from './statusSocket.js'; +import { socketAuth } from '../middleware/auth.js'; + +export function registerSockets(io, { dockerService, appState, seedService, sessionMiddleware, logger, config }) { + const auth = socketAuth(sessionMiddleware); + + const logs = io.of('/logs'); + logs.use(auth); + registerLogsSocket(logs, { dockerService, appState, logger: logger?.child('logs-socket') }); + + const status = io.of('/status'); + status.use(auth); + registerStatusSocket(status, { dockerService, appState, seedService, logger: logger?.child('status-socket'), config }); + + return { logs, status }; +} diff --git a/src/sockets/logsSocket.js b/src/sockets/logsSocket.js new file mode 100644 index 0000000..bcdeacc --- /dev/null +++ b/src/sockets/logsSocket.js @@ -0,0 +1,26 @@ +export function registerLogsSocket(namespace, { dockerService, appState }) { + namespace.on('connection', async (socket) => { + let stream = null; + try { + stream = await dockerService.logStream(); + const onData = (chunk) => socket.emit('log', chunk.toString()); + stream.on('data', onData); + socket.on('disconnect', () => { + stream?.off?.('data', onData); + stream?.destroy?.(); + }); + } catch (err) { + socket.emit('log', `[craftdock] log stream unavailable: ${err.message}\n`); + } + + socket.on('command', async (cmd) => { + try { + const adapter = await appState.getAdapter(); + const output = await adapter.sendCommand(cmd); + if (output) socket.emit('log', `> ${cmd}\n${output}\n`); + } catch (err) { + socket.emit('log', `[craftdock] command failed: ${err.message}\n`); + } + }); + }); +} diff --git a/src/sockets/statusSocket.js b/src/sockets/statusSocket.js new file mode 100644 index 0000000..49ceeb9 --- /dev/null +++ b/src/sockets/statusSocket.js @@ -0,0 +1,68 @@ +import { updatePlayerDirectory } from '../services/playerDirectory.js'; + + +export async function buildStatusPayload({ dockerService, appState, seedService, logger, config }) { + const info = await dockerService.inspect(); + if (info.state !== 'running') { + if (seedService?.clearCache) { + seedService.clearCache(); + } + return { + state: info.state, type: info.type, + cpuPct: 0, memUsedMb: 0, memPct: 0, + players: { online: 0, max: 0, players: [] }, + seed: null, + }; + } + const stats = await dockerService.stats(); + let players = { online: 0, max: 0, players: [] }; + let seed = null; + let type = info.type; + + try { + const adapter = await appState.getAdapter(); + const edition = adapter._edition === 'bedrock' ? 'bedrock' : 'java'; + type = edition === 'bedrock' ? 'BEDROCK' : 'JAVA'; + + players = await adapter.listPlayers(); + if (config) { + // Keep the XUID directory fresh (captures Player Spawned lines) but do NOT + // ship the whole list on the 2s status feed — it can hold thousands of + // entries. The Players tab fetches it page-by-page from GET /players/directory. + await updatePlayerDirectory({ + dataRoot: config.mcDataPath, + dockerService, + edition, + logger, + minIntervalMs: 5000, + }); + } + seed = await seedService.resolve(adapter, edition); + } catch (err) { + logger?.warn('status payload: command channel or step failed', { error: err.message, stack: err.stack }); + } + return { state: info.state, type, ...stats, players, seed }; +} + + + +export function registerStatusSocket(namespace, { dockerService, appState, seedService, logger, config, intervalMs = 2000 }) { + namespace.on('connection', (socket) => { + logger?.info('status socket connected', { id: socket.id }); + const push = async () => { + try { + socket.emit('status', await buildStatusPayload({ dockerService, appState, seedService, logger, config })); + } catch (err) { + logger?.error('status push failed', { error: err.message, stack: err.stack }); + socket.emit('status', { state: 'error', error: err.message }); + } + }; + push(); + const timer = setInterval(push, intervalMs); + socket.on('disconnect', () => { + logger?.info('status socket disconnected', { id: socket.id }); + clearInterval(timer); + }); + }); +} + diff --git a/test/adapters/bedrockAdapter.test.js b/test/adapters/bedrockAdapter.test.js new file mode 100644 index 0000000..79db776 --- /dev/null +++ b/test/adapters/bedrockAdapter.test.js @@ -0,0 +1,184 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createBedrockAdapter } from '../../src/adapters/bedrockAdapter.js'; +import { NotSupportedError } from '../../src/adapters/serverAdapter.js'; + +function make(sendImpl) { + const stdin = { send: vi.fn(sendImpl || (async () => 'ok')) }; + return { stdin, adapter: createBedrockAdapter(stdin) }; +} + +describe('BedrockAdapter', () => { + it('whitelistAdd maps to "allowlist add"', async () => { + const { stdin, adapter } = make(); + await adapter.whitelistAdd('steve'); + expect(stdin.send).toHaveBeenCalledWith('allowlist add "steve"'); + }); + + it('whitelistRemove maps to "allowlist remove"', async () => { + const { stdin, adapter } = make(); + await adapter.whitelistRemove('steve'); + expect(stdin.send).toHaveBeenCalledWith('allowlist remove "steve"'); + }); + + it('ban throws NotSupportedError', async () => { + const { adapter } = make(); + await expect(adapter.ban('x')).rejects.toBeInstanceOf(NotSupportedError); + }); + + it('does not advertise ban capability', () => { + const { adapter } = make(); + expect(adapter.capabilities.has('ban')).toBe(false); + }); + + it('does not expose getSeed (Bedrock has no seed command; read from level.dat)', () => { + const { adapter } = make(); + expect(adapter.getSeed).toBeUndefined(); + }); + + it('getPlayerPosition parses querytarget response when it is clean JSON', async () => { + const { adapter } = make(async (cmd) => { + if (cmd.startsWith('querytarget')) { + return '[{"position":{"x":15.0,"y":70.0,"z":-30.0},"dimension":1}]'; + } + return ''; + }); + const pos = await adapter.getPlayerPosition('alex'); + expect(pos).toEqual({ x: 15, y: 70, z: -30, dimension: 'nether' }); + }); + + it('getPlayerPosition parses querytarget response when it has a log/text prefix', async () => { + const { adapter } = make(async (cmd) => { + if (cmd.startsWith('querytarget')) { + return '[2026-07-18 23:08:38:396 INFO] Target data: [\n' + + ' {\n' + + ' "dimension" : 0,\n' + + ' "position" : {\n' + + ' "x" : 12.5,\n' + + ' "y" : 64.0,\n' + + ' "z" : -25.5\n' + + ' }\n' + + ' }\n' + + ']'; + } + return ''; + }); + const pos = await adapter.getPlayerPosition('alex'); + expect(pos).toEqual({ x: 12.5, y: 64, z: -25.5, dimension: 'overworld' }); + }); + + it('getPlayerPosition returns null when querytarget fails, and does not fall back to tp', async () => { + const { stdin, adapter } = make(async (cmd) => { + if (cmd.startsWith('querytarget')) { + return 'Unknown command or failure'; + } + return 'Teleported alex to 12.5, 64, -25.5'; + }); + const pos = await adapter.getPlayerPosition('alex'); + expect(pos).toBeNull(); + expect(stdin.send).not.toHaveBeenCalledWith(expect.stringContaining('tp ')); + }); + + it('whitelistOn maps to "allowlist on"', async () => { + const { stdin, adapter } = make(); + await adapter.whitelistOn(); + expect(stdin.send).toHaveBeenCalledWith('allowlist on'); + }); + + it('whitelistOff maps to "allowlist off"', async () => { + const { stdin, adapter } = make(); + await adapter.whitelistOff(); + expect(stdin.send).toHaveBeenCalledWith('allowlist off'); + }); + + it('whitelistList parses "allowlist list" output into array', async () => { + const { adapter } = make(async () => 'There are 2 allowlisted players: steve, alex'); + const list = await adapter.whitelistList(); + expect(list).toEqual(['steve', 'alex']); + }); + + it('whitelistList returns empty array when no players', async () => { + const { adapter } = make(async () => 'There are 0 allowlisted players:'); + const list = await adapter.whitelistList(); + expect(list).toEqual([]); + }); + + it('sendCommand sends command unmodified if no slash', async () => { + const { stdin, adapter } = make(); + await adapter.sendCommand('gamerule showcoordinates true'); + expect(stdin.send).toHaveBeenCalledWith('gamerule showcoordinates true'); + }); + + it('sendCommand strips leading slash from command', async () => { + const { stdin, adapter } = make(); + await adapter.sendCommand('/gamerule showcoordinates true'); + expect(stdin.send).toHaveBeenCalledWith('gamerule showcoordinates true'); + }); + + it('exposes whitelistOn/Off/List capabilities', () => { + const { adapter } = make(); + expect(adapter.capabilities.has('whitelistOn')).toBe(true); + expect(adapter.capabilities.has('whitelistOff')).toBe(true); + expect(adapter.capabilities.has('whitelistList')).toBe(true); + }); + + it('saveHold sends "save hold" then polls "save query" until ready', async () => { + const { stdin, adapter } = make(async (cmd) => + cmd === 'save query' ? 'Data saved. Files are now ready to be copied.' : 'ok'); + const ok = await adapter.saveHold(); + expect(ok).toBe(true); + expect(stdin.send).toHaveBeenCalledWith('save hold'); + expect(stdin.send).toHaveBeenCalledWith('save query'); + }); + + it('saveResume sends "save resume"', async () => { + const { stdin, adapter } = make(); + await adapter.saveResume(); + expect(stdin.send).toHaveBeenCalledWith('save resume'); + }); + + it('queryUniqueId extracts the uniqueId from querytarget output', async () => { + const { adapter } = make(async (cmd) => + cmd.startsWith('querytarget') + ? '[{"dimension":0,"position":{"x":1,"y":2,"z":3},"uniqueId":"-8589934591"}]' + : 'ok'); + expect(await adapter.queryUniqueId('alex')).toBe('-8589934591'); + }); + + it('queryUniqueId extracts decimal uniqueId from noisy log output', async () => { + const { adapter } = make(async (cmd) => + cmd.startsWith('querytarget') + ? '[2026-07-22 22:43:05:123 INFO] Target data: [{"dimension":0,"position":{"x":100,"y":64,"z":-200},"uniqueId":2535407895138987}]' + : 'ok'); + expect(await adapter.queryUniqueId('Lucasrguerra')).toBe('2535407895138987'); + }); + + it('queryUniqueId parses the real multi-line 1.26 output (UUID uniqueId, id present)', async () => { + // Captured verbatim from a live bedrock 1.26.33.2 server + const real = [ + '[2026-07-23 02:40:43:441 INFO] Target data: [', + ' {', + ' "dimension" : 0,', + ' "id" : -64424509439,', + ' "position" : {', + ' "x" : 79.52253723144531,', + ' "y" : 98.62001037597656,', + ' "z" : 14.37582015991211', + ' },', + ' "uniqueId" : "657cb1d4-99dd-3123-b27f-d0c27df79710",', + ' "yRot" : -82.85218048095703', + ' }', + ']', + ].join('\n'); + const { adapter } = make(async (cmd) => (cmd.startsWith('querytarget') ? real : 'ok')); + expect(await adapter.queryUniqueId('Lucasrguerra')).toBe('657cb1d4-99dd-3123-b27f-d0c27df79710'); + expect(await adapter.getPlayerPosition('Lucasrguerra')).toEqual({ + x: 79.52253723144531, y: 98.62001037597656, z: 14.37582015991211, dimension: 'overworld', + }); + }); + + it('queryUniqueId returns null when querytarget fails', async () => { + const { adapter } = make(async () => 'Unknown command'); + expect(await adapter.queryUniqueId('alex')).toBeNull(); + }); +}); + diff --git a/test/adapters/bedrockPlayerFile.test.js b/test/adapters/bedrockPlayerFile.test.js new file mode 100644 index 0000000..d7e35a8 --- /dev/null +++ b/test/adapters/bedrockPlayerFile.test.js @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import pkg from 'leveldb-zlib'; +import nbt from 'prismarine-nbt'; +import { createBedrockPlayerFile, parsePlayerBuffer } from '../../src/adapters/playerFile/bedrockPlayerFile.js'; + +const { LevelDB } = pkg; +const here = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE = path.join(here, '../fixtures/bedrock-player.nbt'); +const UUID = '1f633c09-f1f1-481c-b811-1cfa15b21973'; +// Real-world shape: querytarget's uniqueId is the player's MsaId, and the DB +// holds a mapping record player_ → { MsaId, SelfSignedId, ServerId }. +const MSA_ID = '657cb1d4-99dd-3123-b27f-d0c27df79710'; +const SELF_SIGNED_ID = 'a257319c-50e9-3aad-aef3-8fc3e0d4e4c2'; + +function mappingRecord() { + const str = (v) => ({ type: 'string', value: v }); + return nbt.writeUncompressed({ + type: 'compound', + name: '', + value: { + MsaId: str(MSA_ID), + SelfSignedId: str(SELF_SIGNED_ID), + ServerId: str('player_server_' + UUID), + }, + }, 'little'); +} + +let tmp, config; + +beforeAll(async () => { + const buf = fs.readFileSync(FIXTURE); + tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'cd-bpf-')); + const dbPath = path.join(tmp, 'worlds', 'Bedrock level', 'db'); + await fsp.mkdir(dbPath, { recursive: true }); + // Build a real (Mojang-format) LevelDB holding the captured player NBT. + const db = new LevelDB(dbPath, { createIfMissing: true }); + await db.open(); + await db.put(Buffer.from('player_server_' + UUID, 'latin1'), buf); + await db.put(Buffer.from('player_' + MSA_ID, 'latin1'), mappingRecord()); + await db.put(Buffer.from('player_' + SELF_SIGNED_ID, 'latin1'), mappingRecord()); + await db.close(); + config = { mcDataPath: tmp, mcWorldName: 'worlds' }; +}); + +afterAll(async () => { await fsp.rm(tmp, { recursive: true, force: true }); }); + +describe('bedrockPlayerFile', () => { + it('parsePlayerBuffer decodes the captured real-world fixture', async () => { + const d = await parsePlayerBuffer(fs.readFileSync(FIXTURE)); + expect(d.position).not.toBeNull(); + expect(d.health.max).toBe(20); + expect(typeof d.food).toBe('number'); + expect(Array.isArray(d.inventory)).toBe(true); + expect(d.uniqueId).toBe('-8589934591'); + }); + + it('readPlayer returns normalized data for an existing uuid', async () => { + const adapter = createBedrockPlayerFile(config); + const d = await adapter.readPlayer(UUID); + expect(d.uniqueId).toBe('-8589934591'); + expect(d.position).not.toBeNull(); + }); + + it('readPlayer returns null for a missing uuid', async () => { + const adapter = createBedrockPlayerFile(config); + expect(await adapter.readPlayer('00000000-0000-0000-0000-000000000000')).toBeNull(); + }); + + it('findByUniqueId locates the player by UniqueID (int64 or UUID string format)', async () => { + const adapter = createBedrockPlayerFile(config); + const hit = await adapter.findByUniqueId('-8589934591'); + expect(hit).not.toBeNull(); + expect(hit.uuid).toBe(UUID); + + // Modern Bedrock servers output uniqueId in UUID hex string format via querytarget: + // e.g. "657cb1d4-99dd-3123-b27f-d0c27df79710" or corresponding int64 (-8589934591) + const hit2 = await adapter.findByUniqueId('00000000-0000-0000-ffff-fffe00000001'); + expect(hit2).not.toBeNull(); + expect(hit2.uuid).toBe(UUID); + }); + + it('findByUniqueId resolves via the player_ ServerId mapping record', async () => { + const adapter = createBedrockPlayerFile(config); + // querytarget uniqueId (= MsaId) — the case seen against a live server + const hit = await adapter.findByUniqueId(MSA_ID); + expect(hit).not.toBeNull(); + expect(hit.uuid).toBe(UUID); + // SelfSignedId mapping record also resolves + const hit2 = await adapter.findByUniqueId(SELF_SIGNED_ID); + expect(hit2.uuid).toBe(UUID); + }); + + it('listServerUuids enumerates player_server_ keys', async () => { + const adapter = createBedrockPlayerFile(config); + expect(await adapter.listServerUuids()).toContain(UUID); + }); +}); diff --git a/test/adapters/index.test.js b/test/adapters/index.test.js new file mode 100644 index 0000000..95d79d8 --- /dev/null +++ b/test/adapters/index.test.js @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { createAdapter, isBedrock } from '../../src/adapters/index.js'; + +const deps = { + rconService: { send: async () => 'r' }, + stdinService: { send: async () => 's' }, +}; + +describe('adapter factory', () => { + it('isBedrock detects BEDROCK type case-insensitively', () => { + expect(isBedrock('BEDROCK')).toBe(true); + expect(isBedrock('bedrock')).toBe(true); + expect(isBedrock('PAPER')).toBe(false); + expect(isBedrock(null)).toBe(false); + }); + + it('creates a java adapter for PAPER', () => { + const a = createAdapter('PAPER', deps); + expect(a.capabilities.has('ban')).toBe(true); + }); + + it('creates a bedrock adapter for BEDROCK', () => { + const a = createAdapter('BEDROCK', deps); + expect(a.capabilities.has('ban')).toBe(false); + }); +}); diff --git a/test/adapters/javaAdapter.test.js b/test/adapters/javaAdapter.test.js new file mode 100644 index 0000000..bccbaed --- /dev/null +++ b/test/adapters/javaAdapter.test.js @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createJavaAdapter } from '../../src/adapters/javaAdapter.js'; + +function make(sendImpl) { + const rcon = { send: vi.fn(sendImpl || (async () => 'ok')) }; + return { rcon, adapter: createJavaAdapter(rcon) }; +} + +describe('JavaAdapter', () => { + it('whitelistAdd issues "whitelist add"', async () => { + const { rcon, adapter } = make(); + await adapter.whitelistAdd('steve'); + expect(rcon.send).toHaveBeenCalledWith('whitelist add steve'); + }); + + it('ban issues "ban" with reason', async () => { + const { rcon, adapter } = make(); + await adapter.ban('griefer', 'no griefing'); + expect(rcon.send).toHaveBeenCalledWith('ban griefer no griefing'); + }); + + it('give issues "give" with item and count', async () => { + const { rcon, adapter } = make(); + await adapter.give('steve', 'minecraft:diamond', 5); + expect(rcon.send).toHaveBeenCalledWith('give steve minecraft:diamond 5'); + }); + + it('listPlayers parses rcon output', async () => { + const { adapter } = make(async () => 'There are 1 of a max of 20 players online: steve'); + expect(await adapter.listPlayers()).toEqual({ online: 1, max: 20, players: ['steve'] }); + }); + + it('exposes JAVA capabilities', () => { + const { adapter } = make(); + expect(adapter.capabilities.has('ban')).toBe(true); + }); + + it('getSeed parses seed correctly', async () => { + const { adapter } = make(async () => 'Seed: [123456789]'); + const seed = await adapter.getSeed(); + expect(seed).toBe('123456789'); + }); + + it('getPlayerPosition parses coordinates and dimension correctly', async () => { + let callCount = 0; + const { rcon, adapter } = make(async (cmd) => { + callCount++; + if (cmd.startsWith('tp')) { + return 'Teleported steve to 10.5, 64, -20.3'; + } + if (cmd.startsWith('data get')) { + return 'steve has the following entity data: "minecraft:overworld"'; + } + return ''; + }); + const pos = await adapter.getPlayerPosition('steve'); + expect(pos).toEqual({ x: 10.5, y: 64, z: -20.3, dimension: 'overworld' }); + }); + + it('whitelistOn maps to "whitelist on"', async () => { + const { rcon, adapter } = make(); + await adapter.whitelistOn(); + expect(rcon.send).toHaveBeenCalledWith('whitelist on'); + }); + + it('whitelistOff maps to "whitelist off"', async () => { + const { rcon, adapter } = make(); + await adapter.whitelistOff(); + expect(rcon.send).toHaveBeenCalledWith('whitelist off'); + }); + + it('whitelistList parses "whitelist list" output into array', async () => { + const { adapter } = make(async () => 'There are 3 whitelisted players: steve, alex, notch'); + const list = await adapter.whitelistList(); + expect(list).toEqual(['steve', 'alex', 'notch']); + }); + + it('whitelistList returns empty array when no players', async () => { + const { adapter } = make(async () => 'There are 0 whitelisted players:'); + const list = await adapter.whitelistList(); + expect(list).toEqual([]); + }); + + it('sendCommand sends command unmodified if no slash', async () => { + const { rcon, adapter } = make(); + await adapter.sendCommand('gamerule showcoordinates true'); + expect(rcon.send).toHaveBeenCalledWith('gamerule showcoordinates true'); + }); + + it('sendCommand strips leading slash from command', async () => { + const { rcon, adapter } = make(); + await adapter.sendCommand('/gamerule showcoordinates true'); + expect(rcon.send).toHaveBeenCalledWith('gamerule showcoordinates true'); + }); + + it('exposes whitelistOn/Off/List capabilities', () => { + const { adapter } = make(); + expect(adapter.capabilities.has('whitelistOn')).toBe(true); + expect(adapter.capabilities.has('whitelistOff')).toBe(true); + expect(adapter.capabilities.has('whitelistList')).toBe(true); + }); + + it('forceSave issues "save-all flush"', async () => { + const { rcon, adapter } = make(); + await adapter.forceSave(); + expect(rcon.send).toHaveBeenCalledWith('save-all flush'); + }); +}); diff --git a/test/adapters/javaPlayerFile.test.js b/test/adapters/javaPlayerFile.test.js new file mode 100644 index 0000000..2df8f93 --- /dev/null +++ b/test/adapters/javaPlayerFile.test.js @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import nbt from 'prismarine-nbt'; +import { createJavaPlayerFile } from '../../src/adapters/playerFile/javaPlayerFile.js'; + +const UUID = '11111111-2222-3333-4444-555555555555'; +let tmp, config; + +beforeAll(async () => { + const doc = { + type: 'compound', name: '', value: { + Pos: { type: 'list', value: { type: 'double', value: [10.5, 64, -20.3] } }, + Dimension: { type: 'string', value: 'minecraft:the_end' }, + Health: { type: 'float', value: 18 }, + foodLevel: { type: 'int', value: 15 }, + Inventory: { + type: 'list', value: { + type: 'compound', value: [{ + Slot: { type: 'byte', value: 0 }, + id: { type: 'string', value: 'minecraft:dirt' }, + Count: { type: 'byte', value: 64 }, + }], + }, + }, + playerGameType: { type: 'int', value: 0 }, + XpLevel: { type: 'int', value: 3 }, + }, + }; + const dat = zlib.gzipSync(nbt.writeUncompressed(doc, 'big')); + tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'cd-jpf-')); + const pdir = path.join(tmp, 'world', 'playerdata'); + await fsp.mkdir(pdir, { recursive: true }); + await fsp.writeFile(path.join(pdir, `${UUID}.dat`), dat); + config = { mcDataPath: tmp, mcWorldName: 'world' }; +}); + +afterAll(async () => { if (tmp) await fsp.rm(tmp, { recursive: true, force: true }); }); + +describe('javaPlayerFile', () => { + it('reads and normalizes a playerdata .dat', async () => { + const d = await createJavaPlayerFile(config).readPlayer(UUID); + expect(d.position).toEqual({ x: 10.5, y: 64, z: -20.3 }); + expect(d.dimension).toBe('end'); + expect(d.health).toEqual({ current: 18, max: 20 }); + expect(d.food).toBe(15); + expect(d.inventory).toEqual([{ slot: 0, name: 'minecraft:dirt', count: 64 }]); + expect(d.gamemode).toBe('survival'); + }); + + it('returns null when the player has no .dat', async () => { + const d = await createJavaPlayerFile(config).readPlayer('00000000-0000-0000-0000-000000000000'); + expect(d).toBeNull(); + }); +}); diff --git a/test/adapters/nbtPlayer.test.js b/test/adapters/nbtPlayer.test.js new file mode 100644 index 0000000..d17e800 --- /dev/null +++ b/test/adapters/nbtPlayer.test.js @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeBedrock, normalizeJava, int64ToString } from '../../src/adapters/playerFile/nbtPlayer.js'; + +describe('int64ToString', () => { + it('combines [high, low] int32 pair like prismarine-nbt longs', () => { + expect(int64ToString([-2, 1])).toBe('-8589934591'); + expect(int64ToString([0, 5])).toBe('5'); + }); + it('passes through number/bigint', () => { + expect(int64ToString(42)).toBe('42'); + expect(int64ToString(7n)).toBe('7'); + }); +}); + +describe('normalizeBedrock', () => { + it('extracts position, dimension, health, hunger, inventory, uniqueId', () => { + const d = normalizeBedrock({ + Pos: [1.5, 2, 3], DimensionId: 1, + Attributes: [ + { Name: 'minecraft:health', Current: 9.5, Max: 20 }, + { Name: 'minecraft:player.hunger', Current: 17 }, + ], + Inventory: [ + { Slot: 0, Name: 'minecraft:iron_pickaxe', Count: 1 }, + { Slot: 1, Name: '', Count: 0 }, + ], + PlayerGameMode: 1, PlayerLevel: 10, UniqueID: [-2, 1], + }); + expect(d.position).toEqual({ x: 1.5, y: 2, z: 3 }); + expect(d.dimension).toBe('nether'); + expect(d.health).toEqual({ current: 9.5, max: 20 }); + expect(d.food).toBe(17); + expect(d.inventory).toEqual([{ slot: 0, name: 'minecraft:iron_pickaxe', count: 1 }]); + expect(d.gamemode).toBe('creative'); + expect(d.xp).toEqual({ level: 10 }); + expect(d.uniqueId).toBe('-8589934591'); + }); + + it('extracts armor (head/chest/legs/feet) and offhand from their own NBT lists', () => { + const d = normalizeBedrock({ + Armor: [ + { Name: 'minecraft:iron_helmet', Count: 1 }, + { Name: 'minecraft:iron_chestplate', Count: 1 }, + { Name: '', Count: 0 }, + { Name: 'minecraft:leather_boots', Count: 1 }, + { Name: '', Count: 0 }, // 5th body slot on some versions — ignored + ], + Offhand: [{ Name: 'minecraft:shield', Count: 1 }], + }); + expect(d.armor).toEqual({ + head: { name: 'minecraft:iron_helmet', count: 1 }, + chest: { name: 'minecraft:iron_chestplate', count: 1 }, + legs: null, + feet: { name: 'minecraft:leather_boots', count: 1 }, + }); + expect(d.offhand).toEqual({ name: 'minecraft:shield', count: 1 }); + }); + + it('yields empty armor/offhand when the lists are absent or empty-named', () => { + const d = normalizeBedrock({}); + expect(d.armor).toEqual({ head: null, chest: null, legs: null, feet: null }); + expect(d.offhand).toBeNull(); + }); +}); + +describe('normalizeJava', () => { + it('extracts fields from a java playerdata shape', () => { + const d = normalizeJava({ + Pos: [10.5, 64, -20.3], Dimension: 'minecraft:the_end', + Health: 18.0, foodLevel: 15, + Inventory: [{ Slot: 0, id: 'minecraft:dirt', Count: 64 }], + playerGameType: 0, XpLevel: 3, + }); + expect(d.position).toEqual({ x: 10.5, y: 64, z: -20.3 }); + expect(d.dimension).toBe('end'); + expect(d.health).toEqual({ current: 18, max: 20 }); + expect(d.food).toBe(15); + expect(d.inventory).toEqual([{ slot: 0, name: 'minecraft:dirt', count: 64 }]); + expect(d.gamemode).toBe('survival'); + expect(d.xp).toEqual({ level: 3 }); + }); + + it('splits armor (slots 100-103) and offhand (-106) out of the java Inventory list', () => { + const d = normalizeJava({ + Inventory: [ + { Slot: 0, id: 'minecraft:dirt', Count: 64 }, + { Slot: 100, id: 'minecraft:iron_boots', Count: 1 }, + { Slot: 101, id: 'minecraft:iron_leggings', Count: 1 }, + { Slot: 102, id: 'minecraft:iron_chestplate', Count: 1 }, + { Slot: 103, id: 'minecraft:iron_helmet', Count: 1 }, + { Slot: -106, id: 'minecraft:shield', Count: 1 }, + ], + }); + // armor/offhand are NOT part of the regular inventory grid + expect(d.inventory).toEqual([{ slot: 0, name: 'minecraft:dirt', count: 64 }]); + expect(d.armor).toEqual({ + head: { name: 'minecraft:iron_helmet', count: 1 }, + chest: { name: 'minecraft:iron_chestplate', count: 1 }, + legs: { name: 'minecraft:iron_leggings', count: 1 }, + feet: { name: 'minecraft:iron_boots', count: 1 }, + }); + expect(d.offhand).toEqual({ name: 'minecraft:shield', count: 1 }); + }); +}); diff --git a/test/adapters/playerFileIndex.test.js b/test/adapters/playerFileIndex.test.js new file mode 100644 index 0000000..c7bf548 --- /dev/null +++ b/test/adapters/playerFileIndex.test.js @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; +import { createPlayerFileAdapter } from '../../src/adapters/playerFile/index.js'; + +const config = { mcDataPath: '/data', mcWorldName: 'worlds' }; + +describe('createPlayerFileAdapter', () => { + it('returns the bedrock adapter (with findByUniqueId) for bedrock', () => { + const a = createPlayerFileAdapter('bedrock', config); + expect(typeof a.readPlayer).toBe('function'); + expect(typeof a.findByUniqueId).toBe('function'); + }); + + it('returns the java adapter for java', () => { + const a = createPlayerFileAdapter('java', config); + expect(typeof a.readPlayer).toBe('function'); + expect(a.findByUniqueId).toBeUndefined(); + }); +}); diff --git a/test/adapters/serverAdapter.test.js b/test/adapters/serverAdapter.test.js new file mode 100644 index 0000000..4b482c5 --- /dev/null +++ b/test/adapters/serverAdapter.test.js @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { parsePlayerList, parseWhitelistList, CAPABILITIES } from '../../src/adapters/serverAdapter.js'; + +describe('parsePlayerList', () => { + it('parses Java list output', () => { + const r = parsePlayerList( + 'There are 2 of a max of 20 players online: steve, alex' + ); + expect(r).toEqual({ online: 2, max: 20, players: ['steve', 'alex'] }); + }); + + it('parses Bedrock list output', () => { + const r = parsePlayerList('There are 1/10 players online:\nnotch'); + expect(r).toEqual({ online: 1, max: 10, players: ['notch'] }); + }); + + it('handles empty server', () => { + const r = parsePlayerList('There are 0 of a max of 20 players online:'); + expect(r).toEqual({ online: 0, max: 20, players: [] }); + }); +}); + +describe('CAPABILITIES', () => { + it('java supports ban, bedrock does not', () => { + expect(CAPABILITIES.JAVA.has('ban')).toBe(true); + expect(CAPABILITIES.BEDROCK.has('ban')).toBe(false); + }); +}); + +describe('parseWhitelistList', () => { + it('parses Java style text whitelist list', () => { + const res = parseWhitelistList('There are 2 whitelisted players: steve, alex'); + expect(res).toEqual(['steve', 'alex']); + }); + + it('parses Bedrock style JSON allowlist list', () => { + const res = parseWhitelistList( + '41:55:938 INFO] ###* {"command":"allowlist", "result":[{"ignoresPlayerLimit":false,"name":"Lucasrguerra","xuid":"2535407895138987"}]} *###' + ); + expect(res).toEqual(['Lucasrguerra']); + }); + + it('returns empty for an empty Bedrock allowlist (result: null), not garbage', () => { + // This is the exact line that produced ghost "names" from log fragments. + const res = parseWhitelistList( + '[2026-07-18 22:53:21:185 INFO] ###* {"command":"allowlist","result":null} *###' + ); + expect(res).toEqual([]); + }); + + it('returns empty for a Bedrock allowlist with an empty array', () => { + expect(parseWhitelistList('###* {"command":"allowlist","result":[]} *###')).toEqual([]); + }); + + it('never scrapes names out of an unrelated log line with a colon', () => { + expect(parseWhitelistList('[2026-07-18 22:53:23:695 ERROR] Syntax error: Unexpected token')).toEqual([]); + }); + + it('returns empty (not [""]) for the Java empty-whitelist message', () => { + expect(parseWhitelistList('There are no whitelisted players')).toEqual([]); + }); +}); diff --git a/test/appState.test.js b/test/appState.test.js new file mode 100644 index 0000000..dabe255 --- /dev/null +++ b/test/appState.test.js @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createAppState } from '../src/appState.js'; + +const deps = () => ({ + config: { mcEdition: 'auto' }, + dockerService: { inspect: vi.fn().mockResolvedValue({ type: 'PAPER' }) }, + rconService: { send: async () => 'r' }, + stdinService: { send: async () => 's' }, +}); + +describe('appState', () => { + it('resolves a java adapter when TYPE is PAPER', async () => { + const state = createAppState(deps()); + const a = await state.getAdapter(); + expect(a.capabilities.has('ban')).toBe(true); + }); + + it('honors forced bedrock edition without inspecting', async () => { + const d = deps(); + d.config.mcEdition = 'bedrock'; + const state = createAppState(d); + const a = await state.getAdapter(); + expect(a.capabilities.has('ban')).toBe(false); + expect(d.dockerService.inspect).not.toHaveBeenCalled(); + }); + + it('does not cache a fallback when TYPE is unknown, then re-resolves once known', async () => { + const d = deps(); + // First inspect (container stopped) yields no type; second yields BEDROCK. + d.dockerService.inspect = vi + .fn() + .mockResolvedValueOnce({ type: null }) + .mockResolvedValueOnce({ type: 'BEDROCK' }); + const state = createAppState(d); + + const first = await state.getAdapter(); + expect(first.capabilities.has('ban')).toBe(true); // java fallback, not cached + + const second = await state.getAdapter(); + expect(second.capabilities.has('ban')).toBe(false); // now correctly bedrock + expect(d.dockerService.inspect).toHaveBeenCalledTimes(2); + }); + + it('caches once a definitive TYPE is resolved', async () => { + const d = deps(); // inspect always returns PAPER + const state = createAppState(d); + await state.getAdapter(); + await state.getAdapter(); + expect(d.dockerService.inspect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/config.test.js b/test/config.test.js new file mode 100644 index 0000000..c57ccdf --- /dev/null +++ b/test/config.test.js @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { loadConfig } from '../src/config.js'; + +const base = { + ADMIN_PASSWORD_HASH: 'hash', + SESSION_SECRET: 'secret', +}; + +describe('loadConfig', () => { + it('applies defaults for optional vars', () => { + const c = loadConfig(base); + expect(c.mcContainerName).toBe('craftdock-mc-server'); + expect(c.rconPort).toBe(25575); + expect(c.port).toBe(3000); + expect(c.mcEdition).toBe('auto'); + expect(c.maxUploadBytes).toBe(1024 * 1024 * 1024); + }); + + it('throws when required vars are missing', () => { + expect(() => loadConfig({})).toThrow(/ADMIN_PASSWORD_HASH/); + }); + + it('does not require RCON_PASSWORD (Java-only), defaulting it to empty', () => { + const c = loadConfig(base); + expect(c.rconPassword).toBe(''); + }); + + it('accepts a base64-encoded hash when the raw hash is absent', () => { + const realHash = '$2a$12$YjrtHh2BNcUT0P76L2ZUF.mCtxzUwN.gZ4siUqji2oxrDbIXRdvfe'; + const b64 = Buffer.from(realHash, 'utf8').toString('base64'); + const c = loadConfig({ SESSION_SECRET: 's', ADMIN_PASSWORD_HASH_B64: b64 }); + expect(c.adminPasswordHash).toBe(realHash); + }); + + it('prefers the raw hash over the base64 one when both are set', () => { + const b64 = Buffer.from('other', 'utf8').toString('base64'); + const c = loadConfig({ SESSION_SECRET: 's', ADMIN_PASSWORD_HASH: 'raw', ADMIN_PASSWORD_HASH_B64: b64 }); + expect(c.adminPasswordHash).toBe('raw'); + }); + + it('throws when neither raw nor base64 hash is provided', () => { + expect(() => loadConfig({ SESSION_SECRET: 's' })).toThrow(/ADMIN_PASSWORD_HASH/); + }); + + it('coerces numeric env vars', () => { + const c = loadConfig({ ...base, RCON_PORT: '25580', PORT: '8080' }); + expect(c.rconPort).toBe(25580); + expect(c.port).toBe(8080); + }); + + it('falls back to defaults for empty string or non-numeric numeric env vars', () => { + const c = loadConfig({ ...base, RCON_PORT: '', PORT: 'abc' }); + expect(c.rconPort).toBe(25575); + expect(c.port).toBe(3000); + }); +}); diff --git a/test/fixtures/bedrock-player.nbt b/test/fixtures/bedrock-player.nbt new file mode 100644 index 0000000000000000000000000000000000000000..974c6ddc4251428da62214d28bef17620dcc232e GIT binary patch literal 10105 zcmeHN&2JmW6`vJFnWU@_Oa6$zlDKJ#7A=g%xdcHBTOuuos2vNYoUaQpwTv?6!sJUr@6RV zV>>~hoK6@BT{{Pr%gbz+YpD0lPWcS0he`?PPoo-Zh#n8!;2}Snn{Yf}vT})~AbiDh ziM2M_$niv1@m}!ZKyWu0Oc@=|Fv4h-5xZwh%rXLKp3y~vQBS(A9Jdws;I~XK$Z6<> z(JZ^;JH|xB?%Px^fKqnfCVIVTup4@^JM8f8u&so4biloC#`gQCPj64zMkAB$vu9s@ zHP1FNX9P;wrop#mupP-mF(Mx&8rFXJ%STgI(dhqSb=_bUdbUtw9thR`k-1(nxVrq1 zFYsdw-Z1<)<2H)VnF;b^?7ak(81(o~;zyGo4+3(c9q;#-jvF&>XTuaBJ@3HthJL)G zrSbzudZ`Pe&15LI7^IE4XgU5a3(`i)?D)Gxxpjk?8+M0NHZQVaeU8m^j~~}lHra`I z)nL<8PM0)--)yJn2sPz->iw6UE3pS-+a{`+cI@D!?&6fuddloaQ;<8VZ7bf-Q4mL^ z;X91X?RVOP(+o`Cw?jKRG`C z_V2IFGxPMxA%~|p8t_n`%rn{{Gj{#!KlTj8XY?e{yq;#BNc42Yp>U_6^Gf41S4Cea zM^KWmF0fsf>tHWb9`A}h$HfurczssdngKlE6B!~Iqt-x#4TKJa9oHzXRLojIv4W3V zthCPRlxji3Kv_oq<~9NGKNvI|WDQNH4*t@~eW@L?`sc%($uHcT$uG}ia;X~AJOE7) zTV)yXrVTJFZLoV@cfh@XGF3|m`%ia1c(=NZS!9$*lcskFkW`b}RUkaS@LFaqR|cmn zz+xopbX8l7v{VGCR0L_c2vWHS(n=AeGewXpMUbjRkZMJcR*N8=ErPUG1Zlkp(nb-a z%_2zWiXd$jLE0{YbiN4Eg(657iy&Ppg7iiaq{~H+uFNBq&$2!4x{dU9LnrknTTEMP z>70>%*4W2fHymN_ik?)$vesz=xxlL{VLO3lOBMEUsDe0&RI6;75`GRwxRCFJHkj>}UKdtIT&?m#a2Ea0@N^>T%{G(S8|MNKmID$jMZJPY{%nn8+H`YouSM$s+_?oBtLP^bU^c}GDz2oAYCtl^kxyHw~8RWT?FYy5u~3LL3(EaX=_?$8GVP1>8w%0ou%2(^@QRb z7j26(tf_arzM3!#AiWa?(vu?@YU)Ka^)BxKQo+|fw2#ovuu#+K`>un8j{yPhyRySw zDk7V@iMj=itvdGuj{c2R!`vg~qiaB2j&&M^!J}SXdV!L_A^5%$eGhGgnDk@Gqqa#z zx`oPu5Q%3i2)&33;k3ri2(J;4faE_wEs0eGI8fAb0${ny9tf@nLNy!DlE2b5%=H4Z zPjkRKE#byR%QEVk(xqrgQVuUbFGtX6TO&CX$^;T=xb1k#v3&t(_wh4(*_aTn#^kZ^%=m%~?F5QB%=v z`F!lL>KXQ^*UPkd{@F3d48DHk-B;-S+SW3@$B52P#5i-?I@@=tZF(Pmekg4bnR+#u zd=Rz#AW?bSoY_~hk2f4mvR2m;$dhI>5Tj_0+GYDv1>CJm>Dm%1(8>}fH9C!q^?E<- zs@wnCz5bsMc1l;+W2Zj|avi@!cop_o;^u<;N}0y)hhP8m?Mr{(L3p;fZM*PyX`987 zCTt3SxE`Sych)Xskc%O?;+jQ&7N(pZQv1A;C(i65eS}}foBn2y0JLl^kQT) zYHL--I}WaEj?g#-}bncOGa^j91~^M2_4tAa9rC`DvjeukDZ{4PPA37u%0_< z`RMI_|MbCaBmfM_;UMaGxS2<&wDp~t98zU2?G7(my23_$0z8HSh8~P9Yl%sp4u9#R zQ=W!CM5(XzGy}9<-A##R_}x=6knp{uUGlT#-=cEf^WsZ(OUv-IpGg>e^*=+XE+wGVM z5}^yVxj628jHR^Aha4IUudRI?dggq*Iwt;>aZ^u;BY>5fw9&XY6gqFg@A2g3ie!QL z=ATkXIx!IeQ_e&pek6XC!?%Z@CqxfDZT?3N?g@h%IZJJ1(00^WI8n%K2{Fynf_Hh> zNtxs>Fr~;_059Ca!+0zTG7oh~+8sXXNSXMxY3E7rLpwuy^XCg)KJt5L{p6ux-=J%B zm>S-6Y3k~+RJQp>n$8b^=9+ybWWlyQ5$&FmqjuCVG|@CFSJC#N4qTO|x%O4qO$B); z$3YsuqM_O>Qj?@Gx220bE|e)w8aPvznns~nqVbF8F-n4x*1ch@ODvG+Vs<#O@4JIM zA*Q=24+^KZV_xqXY*Ga>y{AOXpNwS7V*8PBS8}MN3ZKM3Hh<2}8c31H&ajPXfvBaI zw>}E&cz>88d?wX!7l~((Mfc?#;mJTyz>JECh< { + it('formats text logs with timestamp, level, component and message', () => { + let output = ''; + const stream = { + write: (data) => { output += data; }, + }; + const log = createLogger('test-comp', { level: 'info', format: 'text', stream }); + log.info('hello world', { foo: 'bar' }); + + expect(output).toContain('[INFO ] [test-comp] hello world {"foo":"bar"}'); + }); + + it('formats JSON logs when format is json', () => { + let output = ''; + const stream = { + write: (data) => { output += data; }, + }; + const log = createLogger('json-comp', { level: 'info', format: 'json', stream }); + log.info('json message', { count: 42 }); + + const parsed = JSON.parse(output.trim()); + expect(parsed.component).toBe('json-comp'); + expect(parsed.level).toBe('info'); + expect(parsed.msg).toBe('json message'); + expect(parsed.meta).toEqual({ count: 42 }); + }); + + it('filters out logs above threshold level', () => { + let output = ''; + const stream = { + write: (data) => { output += data; }, + }; + const log = createLogger('threshold-comp', { level: 'warn', format: 'text', stream }); + log.info('should be ignored'); + log.warn('should be logged'); + + expect(output).not.toContain('should be ignored'); + expect(output).toContain('should be logged'); + }); + + it('serializes Error objects correctly', () => { + let output = ''; + const stream = { + write: (data) => { output += data; }, + }; + const log = createLogger('error-comp', { level: 'error', format: 'json', stream }); + const err = new Error('something went wrong'); + log.error('failure', { err }); + + const parsed = JSON.parse(output.trim()); + expect(parsed.meta.err.error).toBe('something went wrong'); + expect(parsed.meta.err.stack).toBeDefined(); + }); +}); diff --git a/test/middleware/auth.test.js b/test/middleware/auth.test.js new file mode 100644 index 0000000..006389b --- /dev/null +++ b/test/middleware/auth.test.js @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from 'vitest'; +import { requireAuth, socketAuth } from '../../src/middleware/auth.js'; + +function mockRes() { + return { + statusCode: 200, + body: null, + status(c) { this.statusCode = c; return this; }, + json(b) { this.body = b; return this; }, + }; +} + +describe('requireAuth', () => { + it('calls next when session is authed', () => { + const next = vi.fn(); + requireAuth({ session: { authed: true } }, mockRes(), next); + expect(next).toHaveBeenCalledOnce(); + }); + + it('returns 401 when not authed', () => { + const next = vi.fn(); + const res = mockRes(); + requireAuth({ session: {} }, res, next); + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(401); + expect(res.body).toEqual({ error: 'unauthorized' }); + }); +}); + +describe('socketAuth', () => { + it('calls next with no error when session is authed', () => { + const next = vi.fn(); + const socket = { request: {} }; + const fakeSessionMiddleware = (req, res, callback) => { + req.session = { authed: true }; + callback(); + }; + + const middleware = socketAuth(fakeSessionMiddleware); + middleware(socket, next); + + expect(next).toHaveBeenCalledOnce(); + expect(next).toHaveBeenCalledWith(); + }); + + it('calls next with unauthorized error when not authed', () => { + const next = vi.fn(); + const socket = { request: {} }; + const fakeSessionMiddleware = (req, res, callback) => { + req.session = {}; + callback(); + }; + + const middleware = socketAuth(fakeSessionMiddleware); + middleware(socket, next); + + expect(next).toHaveBeenCalledOnce(); + expect(next).toHaveBeenCalledWith(expect.any(Error)); + expect(next.mock.calls[0][0].message).toBe('unauthorized'); + }); +}); diff --git a/test/routes/auth.test.js b/test/routes/auth.test.js new file mode 100644 index 0000000..f21d068 --- /dev/null +++ b/test/routes/auth.test.js @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import express from 'express'; +import session from 'express-session'; +import request from 'supertest'; +import { createAuthRouter } from '../../src/routes/auth.js'; + +function makeApp() { + const app = express(); + app.use(express.json()); + app.use(session({ secret: 't', resave: false, saveUninitialized: false })); + const authService = { verifyPassword: async (p) => p === 'right' }; + app.use('/api/auth', createAuthRouter(authService)); + return app; +} + +describe('auth routes', () => { + it('logs in with correct password and reports authed', async () => { + const agent = request.agent(makeApp()); + const login = await agent.post('/api/auth/login').send({ password: 'right' }); + expect(login.status).toBe(200); + const me = await agent.get('/api/auth/me'); + expect(me.body).toEqual({ authed: true }); + }); + + it('rejects wrong password', async () => { + const res = await request(makeApp()).post('/api/auth/login').send({ password: 'no' }); + expect(res.status).toBe(401); + }); + + it('logs out', async () => { + const agent = request.agent(makeApp()); + await agent.post('/api/auth/login').send({ password: 'right' }); + await agent.post('/api/auth/logout'); + const me = await agent.get('/api/auth/me'); + expect(me.body).toEqual({ authed: false }); + }); +}); diff --git a/test/routes/players.test.js b/test/routes/players.test.js new file mode 100644 index 0000000..f501bb5 --- /dev/null +++ b/test/routes/players.test.js @@ -0,0 +1,149 @@ +import { describe, it, expect, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { createPlayersRouter } from '../../src/routes/players.js'; +import { NotSupportedError } from '../../src/adapters/serverAdapter.js'; + +function makeApp(adapterOverrides = {}, { propertiesOverrides } = {}) { + const adapter = { + capabilities: new Set(['whitelistAdd', 'whitelistRemove', 'whitelistOn', 'whitelistOff', 'whitelistList', 'ban', 'give']), + _edition: 'java', + listPlayers: vi.fn().mockResolvedValue({ online: 0, max: 20, players: [] }), + whitelistAdd: vi.fn().mockResolvedValue('Added steve'), + whitelistRemove: vi.fn().mockResolvedValue('Removed steve'), + whitelistOn: vi.fn().mockResolvedValue('Whitelist enabled'), + whitelistOff: vi.fn().mockResolvedValue('Whitelist disabled'), + whitelistList: vi.fn().mockResolvedValue(['steve', 'alex']), + ban: vi.fn().mockResolvedValue('Banned'), + give: vi.fn().mockResolvedValue('Gave'), + ...adapterOverrides, + }; + const propertiesService = { + read: vi.fn().mockResolvedValue({ 'white-list': 'true' }), + update: vi.fn().mockResolvedValue({}), + ...propertiesOverrides, + }; + const appState = { getAdapter: vi.fn().mockResolvedValue(adapter) }; + const app = express(); + app.use(express.json()); + app.use('/api/players', createPlayersRouter({ appState, propertiesService, config: { mcDataPath: '/data' } })); + return { app, adapter, propertiesService }; +} + +describe('players routes', () => { + it('rejects a name-requiring action with an empty name (does not call the adapter)', async () => { + const { app, adapter } = makeApp(); + const res = await request(app).post('/api/players/whitelistRemove').send({ name: ' ' }); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'name_required' }); + expect(adapter.whitelistRemove).not.toHaveBeenCalled(); + }); + + it('allows name-less actions (whitelistOn) without a name', async () => { + const { app, adapter } = makeApp(); + const res = await request(app).post('/api/players/whitelistOn').send({}); + expect(res.status).toBe(200); + expect(adapter.whitelistOn).toHaveBeenCalled(); + }); + + it('GET / returns players and capability list (no embedded directory)', async () => { + const { app } = makeApp(); + const res = await request(app).get('/api/players'); + expect(res.body.capabilities).toContain('ban'); + expect(res.body.players).toEqual({ online: 0, max: 20, players: [] }); + expect(res.body.players.directory).toBeUndefined(); + }); + + it('GET /directory returns a paginated shape with online annotation', async () => { + const { app } = makeApp(); + const res = await request(app).get('/api/players/directory?page=1&pageSize=25'); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ page: 1, pageSize: 25, total: expect.any(Number) }); + expect(Array.isArray(res.body.items)).toBe(true); + }); + + it('GET /detail requires an xuid', async () => { + const { app } = makeApp(); + const res = await request(app).get('/api/players/detail'); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'xuid_required' }); + }); + + it('GET /detail returns 404 for an unknown player', async () => { + const { app } = makeApp(); + const res = await request(app).get('/api/players/detail?xuid=doesnotexist'); + expect(res.status).toBe(404); + }); + + it('GET / returns whitelistEnabled and whitelist', async () => { + const { app } = makeApp(); + const res = await request(app).get('/api/players'); + expect(res.body.whitelistEnabled).toBe(true); + expect(res.body.whitelist).toEqual(['steve', 'alex']); + }); + + it('GET / returns whitelistEnabled=false when property is false', async () => { + const { app } = makeApp({}, { + propertiesOverrides: { read: vi.fn().mockResolvedValue({ 'white-list': 'false' }) }, + }); + const res = await request(app).get('/api/players'); + expect(res.body.whitelistEnabled).toBe(false); + }); + + it('GET / reads allow-list property for bedrock edition', async () => { + const { app } = makeApp({ _edition: 'bedrock' }, { + propertiesOverrides: { read: vi.fn().mockResolvedValue({ 'allow-list': 'true' }) }, + }); + const res = await request(app).get('/api/players'); + expect(res.body.whitelistEnabled).toBe(true); + }); + + it('POST /whitelistAdd delegates with name', async () => { + const { app, adapter } = makeApp(); + const res = await request(app).post('/api/players/whitelistAdd').send({ name: 'steve' }); + expect(res.body).toEqual({ ok: true, output: 'Added steve' }); + expect(adapter.whitelistAdd).toHaveBeenCalledWith('steve'); + }); + + it('POST /whitelistOn calls adapter and persists to properties', async () => { + const { app, adapter, propertiesService } = makeApp(); + const res = await request(app).post('/api/players/whitelistOn').send({}); + expect(res.body.ok).toBe(true); + expect(adapter.whitelistOn).toHaveBeenCalled(); + expect(propertiesService.update).toHaveBeenCalledWith({ 'white-list': 'true' }); + }); + + it('POST /whitelistOff calls adapter and persists to properties', async () => { + const { app, adapter, propertiesService } = makeApp(); + const res = await request(app).post('/api/players/whitelistOff').send({}); + expect(res.body.ok).toBe(true); + expect(adapter.whitelistOff).toHaveBeenCalled(); + expect(propertiesService.update).toHaveBeenCalledWith({ 'white-list': 'false' }); + }); + + it('POST /whitelistOn for bedrock persists allow-list property', async () => { + const { app, adapter, propertiesService } = makeApp({ _edition: 'bedrock' }); + const res = await request(app).post('/api/players/whitelistOn').send({}); + expect(res.body.ok).toBe(true); + expect(propertiesService.update).toHaveBeenCalledWith({ 'allow-list': 'true' }); + }); + + it('POST /give passes item and count', async () => { + const { app, adapter } = makeApp(); + await request(app).post('/api/players/give').send({ name: 'steve', item: 'minecraft:dirt', count: 3 }); + expect(adapter.give).toHaveBeenCalledWith('steve', 'minecraft:dirt', 3); + }); + + it('returns 409 on NotSupportedError', async () => { + const { app } = makeApp({ ban: vi.fn().mockRejectedValue(new NotSupportedError('ban')) }); + const res = await request(app).post('/api/players/ban').send({ name: 'x' }); + expect(res.status).toBe(409); + expect(res.body).toEqual({ error: 'not_supported' }); + }); + + it('returns 400 for unknown action', async () => { + const { app } = makeApp(); + const res = await request(app).post('/api/players/frobnicate').send({ name: 'x' }); + expect(res.status).toBe(400); + }); +}); diff --git a/test/routes/properties.test.js b/test/routes/properties.test.js new file mode 100644 index 0000000..442dbdf --- /dev/null +++ b/test/routes/properties.test.js @@ -0,0 +1,82 @@ +import { describe, it, expect, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { createPropertiesRouter } from '../../src/routes/properties.js'; + +function makeApp() { + const propertiesService = { + read: vi.fn().mockResolvedValue({ difficulty: 'easy', 'max-players': '20' }), + update: vi.fn().mockImplementation(async (p) => ({ difficulty: 'easy', 'max-players': '20', ...p })), + }; + const app = express(); + app.use(express.json()); + app.use('/api/properties', createPropertiesRouter({ propertiesService })); + return { app, propertiesService }; +} + +describe('properties routes', () => { + it('GET / returns properties', async () => { + const { app } = makeApp(); + const res = await request(app).get('/api/properties'); + expect(res.body.properties.difficulty).toBe('easy'); + }); + + it('GET / fills defaults for managed keys missing from the file (except motd)', async () => { + const { app } = makeApp(); + const res = await request(app).get('/api/properties'); + const p = res.body.properties; + // present in the file → kept as-is + expect(p.difficulty).toBe('easy'); + expect(p['max-players']).toBe('20'); + // absent from the file → default applied + expect(p.pvp).toBe('true'); + expect(p['keep-inventory']).toBe('false'); + expect(p.hardcore).toBe('false'); + expect(p['allow-flight']).toBe('false'); + expect(p['force-gamemode']).toBe('false'); + expect(p['allow-nether']).toBe('true'); + expect(p['enable-command-block']).toBe('false'); + expect(p['spawn-monsters']).toBe('true'); + expect(p['spawn-animals']).toBe('true'); + expect(p['spawn-npcs']).toBe('true'); + expect(p.gamemode).toBe('survival'); + expect(p['view-distance']).toBe('10'); + expect(p['simulation-distance']).toBe('10'); + expect(p['spawn-protection']).toBe('16'); + // motd deliberately has no default + expect(p.motd).toBeUndefined(); + }); + + it('GET / serves pure defaults when server.properties does not exist yet', async () => { + const propertiesService = { + read: vi.fn().mockRejectedValue(Object.assign(new Error('nope'), { code: 'ENOENT' })), + }; + const app = express(); + app.use('/api/properties', createPropertiesRouter({ propertiesService })); + const res = await request(app).get('/api/properties'); + expect(res.status).toBe(200); + expect(res.body.properties.pvp).toBe('true'); + expect(res.body.properties.gamemode).toBe('survival'); + }); + + it('PUT / accepts valid values', async () => { + const { app, propertiesService } = makeApp(); + const res = await request(app).put('/api/properties').send({ properties: { difficulty: 'hard' } }); + expect(res.status).toBe(200); + expect(propertiesService.update).toHaveBeenCalledWith({ difficulty: 'hard' }); + }); + + it('PUT / rejects invalid enum with 400', async () => { + const { app } = makeApp(); + const res = await request(app).put('/api/properties').send({ properties: { difficulty: 'banana' } }); + expect(res.status).toBe(400); + expect(res.body.field).toBe('difficulty'); + }); + + it('PUT / rejects non-numeric max-players', async () => { + const { app } = makeApp(); + const res = await request(app).put('/api/properties').send({ properties: { 'max-players': 'lots' } }); + expect(res.status).toBe(400); + expect(res.body.field).toBe('max-players'); + }); +}); diff --git a/test/routes/status.test.js b/test/routes/status.test.js new file mode 100644 index 0000000..4051ecf --- /dev/null +++ b/test/routes/status.test.js @@ -0,0 +1,54 @@ +import { describe, it, expect, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { createStatusRouter } from '../../src/routes/status.js'; +import { ContainerNotFoundError } from '../../src/services/dockerService.js'; + +function makeApp(overrides = {}) { + const dockerService = { + inspect: vi.fn().mockResolvedValue({ state: 'running', type: 'PAPER' }), + stats: vi.fn().mockResolvedValue({ cpuPct: 10, memUsedMb: 512, memPct: 50 }), + start: vi.fn().mockResolvedValue(), + stop: vi.fn().mockResolvedValue(), + restart: vi.fn().mockResolvedValue(), + kill: vi.fn().mockResolvedValue(), + ...overrides.dockerService, + }; + const adapter = { listPlayers: vi.fn().mockResolvedValue({ online: 1, max: 20, players: ['a'] }) }; + const appState = { + getEdition: vi.fn().mockResolvedValue('java'), + getAdapter: vi.fn().mockResolvedValue(adapter), + }; + const app = express(); + app.use(express.json()); + app.use('/api/status', createStatusRouter({ dockerService, appState })); + return { app, dockerService }; +} + +describe('status routes', () => { + it('GET / returns aggregated status', async () => { + const { app } = makeApp(); + const res = await request(app).get('/api/status'); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + state: 'running', edition: 'java', cpuPct: 10, memPct: 50, + players: { online: 1, max: 20, players: ['a'] }, + }); + }); + + it('POST /start delegates to dockerService', async () => { + const { app, dockerService } = makeApp(); + const res = await request(app).post('/api/status/start'); + expect(res.body).toEqual({ ok: true }); + expect(dockerService.start).toHaveBeenCalled(); + }); + + it('POST /start returns 404 when container missing', async () => { + const { app } = makeApp({ + dockerService: { start: vi.fn().mockRejectedValue(new ContainerNotFoundError('mc')) }, + }); + const res = await request(app).post('/api/status/start'); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'not_found' }); + }); +}); diff --git a/test/routes/world.test.js b/test/routes/world.test.js new file mode 100644 index 0000000..a311f39 --- /dev/null +++ b/test/routes/world.test.js @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { PassThrough } from 'node:stream'; +import { createWorldRouter } from '../../src/routes/world.js'; + +function noopUpload() { + // fake multer middleware that sets req.file + return { single: () => (req, _res, next) => { req.file = { path: '/tmp/up.zip' }; next(); } }; +} + +function makeApp(worldOverrides = {}) { + const worldService = { + regen: vi.fn().mockResolvedValue({ ok: true }), + importWorld: vi.fn().mockResolvedValue({ ok: true }), + createDownloadStream: vi.fn().mockImplementation(() => { + const s = new PassThrough(); + process.nextTick(() => { s.end('zipbytes'); }); + return s; + }), + ...worldOverrides, + }; + const app = express(); + app.use(express.json()); + app.use('/api/world', createWorldRouter({ worldService, upload: noopUpload() })); + return { app, worldService }; +} + +describe('world routes', () => { + it('POST /regen delegates with seed', async () => { + const { app, worldService } = makeApp(); + const res = await request(app).post('/api/world/regen').send({ seed: '99988' }); + expect(res.body).toEqual({ ok: true }); + expect(worldService.regen).toHaveBeenCalledWith('99988'); + }); + + it('GET /download streams a zip', async () => { + const { app } = makeApp(); + const res = await request(app).get('/api/world/download'); + expect(res.headers['content-type']).toContain('application/zip'); + expect(res.text).toContain('zipbytes'); + }); + + it('POST /upload imports the world', async () => { + const { app, worldService } = makeApp(); + const res = await request(app).post('/api/world/upload'); + expect(res.body).toEqual({ ok: true }); + expect(worldService.importWorld).toHaveBeenCalledWith('/tmp/up.zip'); + }); + + it('POST /upload returns 400 on bad zip', async () => { + const { app } = makeApp({ importWorld: vi.fn().mockRejectedValue(new Error('bad zip')) }); + const res = await request(app).post('/api/world/upload'); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'invalid_world' }); + }); +}); diff --git a/test/server.integration.test.js b/test/server.integration.test.js new file mode 100644 index 0000000..c2d77d8 --- /dev/null +++ b/test/server.integration.test.js @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from 'vitest'; +import request from 'supertest'; +import bcrypt from 'bcryptjs'; +import { createApp } from '../src/server.js'; + +function fakeDeps() { + const config = { + adminPasswordHash: bcrypt.hashSync('pw', 10), + sessionSecret: 'test-secret', + nodeEnv: 'test', + mcDataPath: '/data', + }; + const dockerService = { + inspect: vi.fn().mockResolvedValue({ state: 'exited', type: 'PAPER' }), + stats: vi.fn(), start: vi.fn(), stop: vi.fn(), restart: vi.fn(), kill: vi.fn(), + }; + const appState = { getEdition: vi.fn().mockResolvedValue('java'), getAdapter: vi.fn() }; + const propertiesService = { read: vi.fn().mockResolvedValue({}), update: vi.fn() }; + const worldService = {}; + const authService = { verifyPassword: async (p) => p === 'pw' }; + const upload = { single: () => (req, _res, next) => next() }; + return { config, dockerService, appState, propertiesService, worldService, authService, upload }; +} + +describe('server auth guard', () => { + it('blocks /api/status when unauthenticated', async () => { + const { app } = createApp(fakeDeps()); + const res = await request(app).get('/api/status'); + expect(res.status).toBe(401); + }); + + it('allows /api/status after login', async () => { + const { app } = createApp(fakeDeps()); + const agent = request.agent(app); + await agent.post('/api/auth/login').send({ password: 'pw' }); + const res = await agent.get('/api/status'); + expect(res.status).toBe(200); + expect(res.body.state).toBe('exited'); + }); +}); diff --git a/test/services/authService.test.js b/test/services/authService.test.js new file mode 100644 index 0000000..570f031 --- /dev/null +++ b/test/services/authService.test.js @@ -0,0 +1,23 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import bcrypt from 'bcryptjs'; +import { createAuthService } from '../../src/services/authService.js'; + +describe('authService', () => { + let svc; + beforeAll(() => { + const hash = bcrypt.hashSync('correct', 12); + svc = createAuthService({ adminPasswordHash: hash }); + }); + + it('accepts the correct password', async () => { + expect(await svc.verifyPassword('correct')).toBe(true); + }); + + it('rejects a wrong password', async () => { + expect(await svc.verifyPassword('wrong')).toBe(false); + }); + + it('rejects empty input without throwing', async () => { + expect(await svc.verifyPassword('')).toBe(false); + }); +}); diff --git a/test/services/bedrockIdentityBridge.test.js b/test/services/bedrockIdentityBridge.test.js new file mode 100644 index 0000000..643e0d3 --- /dev/null +++ b/test/services/bedrockIdentityBridge.test.js @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import fsp from 'node:fs/promises'; +import { createBedrockIdentityBridge } from '../../src/services/bedrockIdentityBridge.js'; + +vi.mock('node:fs/promises'); + +describe('bedrockIdentityBridge', () => { + let store; + beforeEach(() => { + store = null; + vi.mocked(fsp.writeFile).mockImplementation(async (_p, data) => { store = data; }); + vi.mocked(fsp.readFile).mockImplementation(async () => { + if (store == null) throw new Error('ENOENT'); + return store; + }); + }); + + it('learn binds xuid to the LevelDB uuid when uniqueId matches', async () => { + const fileAdapter = { findByUniqueId: vi.fn(async (u) => u === '-8589934591' ? { uuid: 'abc-uuid', data: {} } : null) }; + const bridge = createBedrockIdentityBridge({ dataRoot: '/data', fileAdapter }); + + const binding = await bridge.learn({ xuid: '2535407895138987', name: 'Lucasrguerra', uniqueId: '-8589934591' }); + expect(binding.leveldbUuid).toBe('abc-uuid'); + expect(await bridge.resolveLeveldbUuid('2535407895138987')).toBe('abc-uuid'); + }); + + it('learn returns null and persists nothing when uniqueId matches no player', async () => { + const fileAdapter = { findByUniqueId: vi.fn(async () => null) }; + const bridge = createBedrockIdentityBridge({ dataRoot: '/data', fileAdapter }); + expect(await bridge.learn({ xuid: 'x', name: 'n', uniqueId: '-1' })).toBeNull(); + expect(store).toBeNull(); + }); + + it('learn binds xuid to fallbackUuid when provided even without uniqueId', async () => { + const fileAdapter = { findByUniqueId: vi.fn() }; + const bridge = createBedrockIdentityBridge({ dataRoot: '/data', fileAdapter }); + + const binding = await bridge.learn({ xuid: '2535407895138987', name: 'Lucasrguerra', fallbackUuid: 'fallback-uuid-123' }); + expect(binding.leveldbUuid).toBe('fallback-uuid-123'); + expect(await bridge.resolveLeveldbUuid('2535407895138987')).toBe('fallback-uuid-123'); + expect(fileAdapter.findByUniqueId).not.toHaveBeenCalled(); + }); + + it('resolveLeveldbUuid returns null for an unknown xuid', async () => { + const bridge = createBedrockIdentityBridge({ dataRoot: '/data', fileAdapter: {} }); + expect(await bridge.resolveLeveldbUuid('nope')).toBeNull(); + }); +}); + diff --git a/test/services/dockerService.test.js b/test/services/dockerService.test.js new file mode 100644 index 0000000..1771d7a --- /dev/null +++ b/test/services/dockerService.test.js @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createDockerService, ContainerNotFoundError } from '../../src/services/dockerService.js'; + +function mockDocker({ inspectData, exists = true } = {}) { + const container = { + start: vi.fn().mockResolvedValue(), + stop: vi.fn().mockResolvedValue(), + restart: vi.fn().mockResolvedValue(), + kill: vi.fn().mockResolvedValue(), + inspect: vi.fn().mockResolvedValue(inspectData), + }; + return { + _container: container, + listContainers: vi.fn().mockResolvedValue( + exists ? [{ Names: ['/craftdock-mc-server'], Id: 'abc' }] : [] + ), + getContainer: vi.fn().mockReturnValue(container), + }; +} + +const config = { mcContainerName: 'craftdock-mc-server' }; + +describe('dockerService', () => { + it('inspect returns state and parsed TYPE', async () => { + const docker = mockDocker({ + inspectData: { + State: { Status: 'running' }, + Config: { Env: ['EULA=TRUE', 'TYPE=PAPER'] }, + }, + }); + const svc = createDockerService(config, docker); + const info = await svc.inspect(); + expect(info).toMatchObject({ found: true, state: 'running', type: 'PAPER' }); + }); + + it('inspect reports not_found when container is absent', async () => { + const svc = createDockerService(config, mockDocker({ exists: false })); + const info = await svc.inspect(); + expect(info).toEqual({ found: false, state: 'not_found', type: null }); + }); + + it('start delegates to the resolved container', async () => { + const docker = mockDocker({ inspectData: { State: {}, Config: { Env: [] } } }); + const svc = createDockerService(config, docker); + await svc.start(); + expect(docker._container.start).toHaveBeenCalledOnce(); + }); + + it('start throws ContainerNotFoundError when absent', async () => { + const svc = createDockerService(config, mockDocker({ exists: false })); + await expect(svc.start()).rejects.toBeInstanceOf(ContainerNotFoundError); + }); + + it('getState works when destructured (no `this` binding)', async () => { + const docker = mockDocker({ + inspectData: { State: { Status: 'running' }, Config: { Env: [] } }, + }); + const { getState } = createDockerService(config, docker); + await expect(getState()).resolves.toBe('running'); + }); +}); diff --git a/test/services/dockerServiceResolve.test.js b/test/services/dockerServiceResolve.test.js new file mode 100644 index 0000000..001c5a5 --- /dev/null +++ b/test/services/dockerServiceResolve.test.js @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from 'vitest'; +import os from 'node:os'; +import { createDockerService } from '../../src/services/dockerService.js'; + +const config = { mcContainerName: 'craftdock-mc-server', mcServiceName: 'minecraft-server' }; + +// Simulates Coolify: the MC container has an auto-generated name (not +// craftdock-mc-server) but carries the compose service/project labels. The panel +// container (os.hostname()) shares the same project. +function mockCoolifyDocker() { + const selfId = os.hostname(); + const mcData = { State: { Status: 'running' }, Config: { Env: ['TYPE=BEDROCK'] } }; + const byId = { + [selfId]: { inspect: vi.fn().mockResolvedValue({ Config: { Labels: { 'com.docker.compose.project': 'proj' } } }) }, + mcid: { inspect: vi.fn().mockResolvedValue(mcData), start: vi.fn().mockResolvedValue() }, + }; + return { + byId, + listContainers: vi.fn().mockResolvedValue([ + { + Names: ['/minecraft-server-proj-abc123'], + Id: 'mcid', + Labels: { 'com.docker.compose.service': 'minecraft-server', 'com.docker.compose.project': 'proj' }, + }, + ]), + getContainer: vi.fn((id) => byId[id] || { inspect: vi.fn().mockRejectedValue(new Error('no such container')) }), + }; +} + +describe('dockerService container resolution', () => { + it('resolves the MC container by compose service label when the name does not match', async () => { + const docker = mockCoolifyDocker(); + const svc = createDockerService(config, docker); + const info = await svc.inspect(); + expect(info).toMatchObject({ found: true, state: 'running', type: 'BEDROCK' }); + }); + + it('scopes the label match to the panel own compose project', async () => { + const docker = mockCoolifyDocker(); + // Same service label but a DIFFERENT project — must NOT match. + docker.listContainers.mockResolvedValue([ + { + Names: ['/minecraft-server-other-xyz'], + Id: 'mcid', + Labels: { 'com.docker.compose.service': 'minecraft-server', 'com.docker.compose.project': 'other-project' }, + }, + ]); + const svc = createDockerService(config, docker); + const info = await svc.inspect(); + expect(info).toEqual({ found: false, state: 'not_found', type: null }); + }); +}); diff --git a/test/services/dockerServiceStats.test.js b/test/services/dockerServiceStats.test.js new file mode 100644 index 0000000..964aaf9 --- /dev/null +++ b/test/services/dockerServiceStats.test.js @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createDockerService } from '../../src/services/dockerService.js'; + +const statsSample = { + cpu_stats: { + cpu_usage: { total_usage: 2000000000 }, + system_cpu_usage: 20000000000, + online_cpus: 2, + }, + precpu_stats: { + cpu_usage: { total_usage: 1000000000 }, + system_cpu_usage: 10000000000, + }, + memory_stats: { usage: 536870912, limit: 1073741824 }, // 512MB / 1GB +}; + +function mockDocker() { + const container = { + inspect: vi.fn().mockResolvedValue({ State: { Status: 'running' }, Config: { Env: [] } }), + stats: vi.fn().mockResolvedValue(statsSample), + }; + return { + listContainers: vi.fn().mockResolvedValue([{ Names: ['/mc'], Id: 'x' }]), + getContainer: vi.fn().mockReturnValue(container), + }; +} + +describe('dockerService.stats', () => { + it('computes cpu and memory percentages', async () => { + const svc = createDockerService({ mcContainerName: 'mc' }, mockDocker()); + const s = await svc.stats(); + // cpuDelta=1e9, sysDelta=1e10 -> 0.1 * 2 cpus * 100 = 20% + expect(s.cpuPct).toBeCloseTo(20, 1); + expect(s.memUsedMb).toBeCloseTo(512, 0); + expect(s.memPct).toBeCloseTo(50, 1); + }); + + it('returns zeros for a malformed/empty stats payload (e.g. stopped container)', async () => { + const docker = mockDocker(); + docker.getContainer().stats.mockResolvedValue({ + cpu_stats: {}, + precpu_stats: {}, + memory_stats: {}, + }); + const svc = createDockerService({ mcContainerName: 'mc' }, docker); + const s = await svc.stats(); + expect(s).toEqual({ cpuPct: 0, memUsedMb: 0, memPct: 0 }); + }); + + it('returns zeros for a completely empty stats payload', async () => { + const docker = mockDocker(); + docker.getContainer().stats.mockResolvedValue({}); + const svc = createDockerService({ mcContainerName: 'mc' }, docker); + const s = await svc.stats(); + expect(s).toEqual({ cpuPct: 0, memUsedMb: 0, memPct: 0 }); + }); +}); diff --git a/test/services/dockerServiceStreams.test.js b/test/services/dockerServiceStreams.test.js new file mode 100644 index 0000000..3ac7a44 --- /dev/null +++ b/test/services/dockerServiceStreams.test.js @@ -0,0 +1,38 @@ +// test/services/dockerServiceStreams.test.js +import { describe, it, expect, vi } from 'vitest'; +import { createDockerService } from '../../src/services/dockerService.js'; + +function mockDocker() { + const container = { + inspect: vi.fn().mockResolvedValue({ State: {}, Config: { Env: [] } }), + logs: vi.fn().mockResolvedValue('LOGSTREAM'), + attach: vi.fn().mockResolvedValue('ATTACHSTREAM'), + }; + return { + _container: container, + listContainers: vi.fn().mockResolvedValue([{ Names: ['/mc'], Id: 'x' }]), + getContainer: vi.fn().mockReturnValue(container), + }; +} + +describe('dockerService streams', () => { + it('logStream requests a following log stream with tail', async () => { + const docker = mockDocker(); + const svc = createDockerService({ mcContainerName: 'mc' }, docker); + const stream = await svc.logStream(); + expect(stream).toBe('LOGSTREAM'); + expect(docker._container.logs).toHaveBeenCalledWith( + expect.objectContaining({ follow: true, stdout: true, stderr: true }) + ); + }); + + it('attach requests a duplex stream with stdin', async () => { + const docker = mockDocker(); + const svc = createDockerService({ mcContainerName: 'mc' }, docker); + const stream = await svc.attach(); + expect(stream).toBe('ATTACHSTREAM'); + expect(docker._container.attach).toHaveBeenCalledWith( + expect.objectContaining({ stream: true, stdin: true }) + ); + }); +}); diff --git a/test/services/firstBootService.test.js b/test/services/firstBootService.test.js new file mode 100644 index 0000000..4da2b34 --- /dev/null +++ b/test/services/firstBootService.test.js @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { ensureFirstBootDefaults, MARKER_FILE } from '../../src/services/firstBootService.js'; + +let tmp, propertiesService, logger; + +beforeEach(async () => { + tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'cd-fb-')); + propertiesService = { update: vi.fn().mockResolvedValue({}) }; + logger = { info: vi.fn(), warn: vi.fn() }; +}); +afterEach(async () => { await fsp.rm(tmp, { recursive: true, force: true }); }); + +const run = (edition = 'bedrock') => + ensureFirstBootDefaults({ config: { mcDataPath: tmp, mcEdition: edition }, propertiesService, logger }); + +describe('ensureFirstBootDefaults', () => { + it('first boot: forces the whitelist property to false and writes the marker', async () => { + await run('bedrock'); + expect(propertiesService.update).toHaveBeenCalledWith({ 'allow-list': 'false' }); + const marker = JSON.parse(await fsp.readFile(path.join(tmp, MARKER_FILE), 'utf8')); + expect(marker.initializedAt).toBeTruthy(); + }); + + it('java edition writes white-list', async () => { + await run('java'); + expect(propertiesService.update).toHaveBeenCalledWith({ 'white-list': 'false' }); + }); + + it('auto edition writes both keys (each server only reads its own)', async () => { + await run('auto'); + expect(propertiesService.update).toHaveBeenCalledWith({ 'allow-list': 'false', 'white-list': 'false' }); + }); + + it('restart (marker present): does not touch properties', async () => { + await fsp.writeFile(path.join(tmp, MARKER_FILE), JSON.stringify({ initializedAt: 'x' })); + await run('bedrock'); + expect(propertiesService.update).not.toHaveBeenCalled(); + }); + + it('never throws: a failing properties write is logged, and no marker is left so it retries next boot', async () => { + propertiesService.update.mockRejectedValue(new Error('disk full')); + await expect(run('bedrock')).resolves.toBeUndefined(); + expect(logger.warn).toHaveBeenCalled(); + await expect(fsp.access(path.join(tmp, MARKER_FILE))).rejects.toThrow(); + }); +}); diff --git a/test/services/playerDetailService.test.js b/test/services/playerDetailService.test.js new file mode 100644 index 0000000..074c25f --- /dev/null +++ b/test/services/playerDetailService.test.js @@ -0,0 +1,97 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createPlayerDetailService } from '../../src/services/playerDetailService.js'; + +function setup({ edition, online, running = true, dir, bridge, readPlayer, listServerUuids, extraAdapter = {} }) { + const adapter = { + _edition: edition, + listPlayers: vi.fn(async () => ({ players: online ? ['Lucas'] : [] })), + queryUniqueId: vi.fn(async () => '-8589934591'), + saveHold: vi.fn(async () => true), + saveResume: vi.fn(async () => {}), + forceSave: vi.fn(async () => {}), + ...extraAdapter, + }; + const deps = { + config: { mcDataPath: '/data' }, + appState: { getAdapter: vi.fn(async () => adapter) }, + dockerService: { inspect: vi.fn(async () => ({ state: running ? 'running' : 'exited' })) }, + readDirectory: vi.fn(async () => dir), + createFileAdapter: vi.fn(() => ({ + readPlayer: vi.fn(readPlayer), + findByUniqueId: vi.fn(), + listServerUuids: vi.fn(async () => listServerUuids ? listServerUuids() : []), + })), + createBridge: vi.fn(() => bridge), + }; + return { adapter, service: createPlayerDetailService(deps), deps }; +} + +describe('playerDetailService', () => { + it('returns null for an unknown xuid', async () => { + const { service } = setup({ edition: 'bedrock', online: false, dir: {}, bridge: {} }); + expect(await service.getDetail('nope')).toBeNull(); + }); + + it('bedrock online: learns the bridge and reads under a save snapshot', async () => { + const bridge = { + learn: vi.fn(async () => ({})), + resolveLeveldbUuid: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce('uuid-1'), + }; + const { adapter, service } = setup({ + edition: 'bedrock', online: true, + dir: { '2535': { name: 'Lucas', xuid: '2535' } }, + bridge, + readPlayer: async (id) => id === 'uuid-1' ? { position: { x: 1, y: 2, z: 3 }, health: { current: 9, max: 20 } } : null, + }); + const d = await service.getDetail('2535'); + expect(d.online).toBe(true); + expect(d.position).toEqual({ x: 1, y: 2, z: 3 }); + expect(bridge.learn).toHaveBeenCalledWith({ xuid: '2535', name: 'Lucas', uniqueId: '-8589934591' }); + expect(adapter.saveHold).toHaveBeenCalled(); + expect(adapter.saveResume).toHaveBeenCalled(); + }); + + it('bedrock offline and never bridged: auto-binds single LevelDB uuid fallback', async () => { + const bridge = { learn: vi.fn(async () => ({})), resolveLeveldbUuid: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce('uuid-fallback-1') }; + const { service } = setup({ + edition: 'bedrock', online: false, running: false, + dir: { '2535': { name: 'Lucas', xuid: '2535' } }, + bridge, + readPlayer: async (id) => id === 'uuid-fallback-1' ? { health: 20 } : null, + listServerUuids: () => ['uuid-fallback-1'], + }); + + const d = await service.getDetail('2535'); + expect(d.needsBridge).toBe(false); + expect(d.health).toBe(20); + expect(bridge.learn).toHaveBeenCalledWith({ xuid: '2535', name: 'Lucas', fallbackUuid: 'uuid-fallback-1' }); + }); + + it('bedrock offline and never bridged with multiple uuids: returns needsBridge', async () => { + const bridge = { learn: vi.fn(), resolveLeveldbUuid: vi.fn(async () => null) }; + const { service } = setup({ + edition: 'bedrock', online: false, + dir: { '2535': { name: 'Lucas', xuid: '2535' } }, + bridge, + readPlayer: async () => null, + listServerUuids: () => ['uuid-1', 'uuid-2'], + }); + const d = await service.getDetail('2535'); + expect(d.needsBridge).toBe(true); + }); + + + + it('java online: forces save then reads the player .dat', async () => { + const { adapter, service } = setup({ + edition: 'java', online: true, + dir: { 'uuid-xyz': { name: 'Lucas', xuid: 'uuid-xyz' } }, + bridge: {}, + readPlayer: async (id) => id === 'uuid-xyz' ? { position: { x: 0, y: 64, z: 0 }, food: 20 } : null, + }); + const d = await service.getDetail('uuid-xyz'); + expect(adapter.forceSave).toHaveBeenCalled(); + expect(d.food).toBe(20); + expect(d.needsBridge).toBe(false); + }); +}); diff --git a/test/services/playerDirectory.test.js b/test/services/playerDirectory.test.js new file mode 100644 index 0000000..88e7e30 --- /dev/null +++ b/test/services/playerDirectory.test.js @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import fsp from 'node:fs/promises'; +import { updatePlayerDirectory, queryDirectory } from '../../src/services/playerDirectory.js'; + +vi.mock('node:fs/promises'); + +// A dockerService whose container returns the given log text, recording the opts +// each `logs()` call was made with (to assert incremental scanning). +function mockDocker(logText) { + const calls = []; + return { + calls, + getContainer: async () => ({ + logs: async (opts) => { calls.push(opts); return Buffer.from(logText); }, + }), + }; +} + +describe('playerDirectory', () => { + let store; + + beforeEach(() => { + store = null; + vi.mocked(fsp.writeFile).mockImplementation(async (_p, data) => { store = data; }); + vi.mocked(fsp.rm).mockResolvedValue(); + // No existing directory / no legacy file by default. + vi.mocked(fsp.readFile).mockRejectedValue(new Error('ENOENT')); + }); + + it('records only players that actually spawned, keyed by XUID', async () => { + const log = [ + 'Player connected: Rejected, xuid: 111', // connect only → rejected by allowlist + 'Player Spawned: Lucasrguerra xuid: 222', // real access + ].join('\n'); + + const list = await updatePlayerDirectory({ dataRoot: '/data', dockerService: mockDocker(log), edition: 'bedrock' }); + + expect(list).toEqual([ + expect.objectContaining({ xuid: '222', name: 'Lucasrguerra' }), + ]); + // Rejected player (connected but never spawned) is absent. + expect(list.find((e) => e.name === 'Rejected')).toBeUndefined(); + expect(JSON.parse(store)).toHaveProperty('222'); + }); + + it('parses gamertags containing spaces', async () => { + const list = await updatePlayerDirectory({ + dataRoot: '/data', dockerService: mockDocker('Player Spawned: Lucas Guerra xuid: 333'), edition: 'bedrock', + }); + expect(list[0]).toMatchObject({ xuid: '333', name: 'Lucas Guerra' }); + }); + + it('scans logs incrementally: bounded tail first, then since', async () => { + // Fresh module so the module-level scan cursor starts at 0 for this test. + vi.resetModules(); + const { updatePlayerDirectory: fresh } = await import('../../src/services/playerDirectory.js'); + + const docker = mockDocker('Player Spawned: steve xuid: 1'); + await fresh({ dataRoot: '/data', dockerService: docker, edition: 'bedrock' }); + expect(docker.calls[0].tail).toBe(2000); + expect(docker.calls[0].since).toBeUndefined(); + + await fresh({ dataRoot: '/data', dockerService: docker, edition: 'bedrock' }); + expect(docker.calls[1].since).toBeGreaterThan(0); + expect(docker.calls[1].tail).toBeUndefined(); + }); + + it('keeps previously-seen players even when no longer spawning (Scenario 2)', async () => { + // Existing directory already has an old player; a new empty scan must not drop them. + vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify({ + '999': { name: 'OldPlayer', xuid: '999', firstSeen: 'x', lastSeen: 'x' }, + })); + const list = await updatePlayerDirectory({ dataRoot: '/data', dockerService: mockDocker(''), edition: 'bedrock' }); + expect(list).toEqual([expect.objectContaining({ xuid: '999', name: 'OldPlayer' })]); + }); +}); + +describe('queryDirectory', () => { + beforeEach(() => { + // 30 players named Player00..Player29, keyed by xuid. + const dir = {}; + for (let i = 0; i < 30; i++) { + const n = String(i).padStart(2, '0'); + dir[`x${n}`] = { xuid: `x${n}`, name: `Player${n}` }; + } + vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify(dir)); + }); + + it('returns the requested page with total', async () => { + const r = await queryDirectory('/data', { page: 2, pageSize: 25 }); + expect(r.total).toBe(30); + expect(r.page).toBe(2); + expect(r.items).toHaveLength(5); + expect(r.items[0].name).toBe('Player25'); + }); + + it('filters by name (case-insensitive) and paginates the filtered set', async () => { + const r = await queryDirectory('/data', { q: 'player0', pageSize: 100 }); + expect(r.total).toBe(10); // Player00..Player09 + expect(r.items.every((e) => e.name.startsWith('Player0'))).toBe(true); + }); + + it('clamps an out-of-range page to the last page', async () => { + const r = await queryDirectory('/data', { page: 99, pageSize: 25 }); + expect(r.page).toBe(2); + expect(r.items).toHaveLength(5); + }); +}); diff --git a/test/services/propertiesService.test.js b/test/services/propertiesService.test.js new file mode 100644 index 0000000..44960d7 --- /dev/null +++ b/test/services/propertiesService.test.js @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createPropertiesService } from '../../src/services/propertiesService.js'; + +const SAMPLE = `#Minecraft server properties +#Mon Jul 18 +difficulty=easy +pvp=true +max-players=20`; + +function makeFs(content) { + const store = { value: content }; + return { + store, + readFile: vi.fn().mockImplementation(async () => store.value), + writeFile: vi.fn().mockImplementation(async (_p, data) => { store.value = data; }), + }; +} + +describe('propertiesService', () => { + it('parses key=value ignoring comments', () => { + const svc = createPropertiesService({ mcDataPath: '/d' }, makeFs(SAMPLE)); + const obj = svc.parse(SAMPLE); + expect(obj).toEqual({ difficulty: 'easy', pvp: 'true', 'max-players': '20' }); + }); + + it('serialize preserves comments and updates values', () => { + const svc = createPropertiesService({ mcDataPath: '/d' }, makeFs(SAMPLE)); + const out = svc.serialize({ difficulty: 'hard', pvp: 'true', 'max-players': '20' }, SAMPLE); + expect(out).toContain('#Minecraft server properties'); + expect(out).toContain('difficulty=hard'); + }); + + it('update merges patch and writes file', async () => { + const fs = makeFs(SAMPLE); + const svc = createPropertiesService({ mcDataPath: '/d' }, fs); + const merged = await svc.update({ difficulty: 'hard' }); + expect(merged.difficulty).toBe('hard'); + expect(fs.writeFile).toHaveBeenCalled(); + expect(fs.store.value).toContain('difficulty=hard'); + }); + + it('update appends new keys not present originally', async () => { + const fs = makeFs(SAMPLE); + const svc = createPropertiesService({ mcDataPath: '/d' }, fs); + await svc.update({ motd: 'Hello' }); + expect(fs.store.value).toContain('motd=Hello'); + }); +}); diff --git a/test/services/rconService.test.js b/test/services/rconService.test.js new file mode 100644 index 0000000..fbd4641 --- /dev/null +++ b/test/services/rconService.test.js @@ -0,0 +1,30 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createRconService, RconUnavailableError } from '../../src/services/rconService.js'; + +function fakeRconClass({ failConnect = false } = {}) { + return class { + static async connect() { + if (failConnect) throw new Error('ECONNREFUSED'); + return { + send: vi.fn().mockResolvedValue('command output'), + end: vi.fn().mockResolvedValue(), + on: vi.fn(), + }; + } + }; +} + +const config = { rconHost: 'h', rconPort: 25575, rconPassword: 'p' }; + +describe('rconService', () => { + it('connects lazily and sends a command', async () => { + const svc = createRconService(config, fakeRconClass()); + const out = await svc.send('list'); + expect(out).toBe('command output'); + }); + + it('throws RconUnavailableError when connect fails', async () => { + const svc = createRconService(config, fakeRconClass({ failConnect: true })); + await expect(svc.send('list')).rejects.toBeInstanceOf(RconUnavailableError); + }); +}); diff --git a/test/services/seedService.test.js b/test/services/seedService.test.js new file mode 100644 index 0000000..e6ffeb7 --- /dev/null +++ b/test/services/seedService.test.js @@ -0,0 +1,56 @@ +import { describe, it, expect, vi } from 'vitest'; +import { parseBedrockSeed, createSeedService } from '../../src/services/seedService.js'; + +function levelDatWithSeed(seed) { + const name = Buffer.from('RandomSeed', 'utf8'); + const value = Buffer.alloc(8); + value.writeBigInt64LE(BigInt(seed)); + return Buffer.concat([Buffer.from([0x04, 0x0a, 0x00]), name, value, Buffer.from('tail')]); +} + +describe('parseBedrockSeed', () => { + it('reads the RandomSeed int64 (little-endian) from a level.dat buffer', () => { + expect(parseBedrockSeed(levelDatWithSeed('682981659723566343'))).toBe('682981659723566343'); + }); + + it('handles negative seeds', () => { + expect(parseBedrockSeed(levelDatWithSeed('-4707462683490150816'))).toBe('-4707462683490150816'); + }); + + it('returns null when RandomSeed is absent', () => { + expect(parseBedrockSeed(Buffer.from('no seed here'))).toBeNull(); + }); +}); + +describe('seedService.resolve', () => { + const config = { mcDataPath: '/data' }; + + it('prefers a non-empty level-seed from server.properties', async () => { + const propertiesService = { read: vi.fn().mockResolvedValue({ 'level-seed': '12345', 'level-name': 'Bedrock level' }) }; + const svc = createSeedService({ config, propertiesService }); + expect(await svc.resolve({}, 'bedrock')).toBe('12345'); + }); + + it('reads bedrock seed from level.dat when level-seed is empty', async () => { + const propertiesService = { read: vi.fn().mockResolvedValue({ 'level-seed': '', 'level-name': 'Bedrock level' }) }; + const fs = { readFile: vi.fn().mockResolvedValue(levelDatWithSeed('682981659723566343')) }; + const svc = createSeedService({ config, propertiesService, fs }); + expect(await svc.resolve({}, 'bedrock')).toBe('682981659723566343'); + expect(fs.readFile).toHaveBeenCalledWith('/data/worlds/Bedrock level/level.dat'); + }); + + it('uses the java adapter command when edition is java', async () => { + const propertiesService = { read: vi.fn().mockResolvedValue({ 'level-seed': '' }) }; + const adapter = { getSeed: vi.fn().mockResolvedValue('99') }; + const svc = createSeedService({ config, propertiesService }); + expect(await svc.resolve(adapter, 'java')).toBe('99'); + expect(adapter.getSeed).toHaveBeenCalled(); + }); + + it('returns null (not throw) when bedrock level.dat is unreadable', async () => { + const propertiesService = { read: vi.fn().mockResolvedValue({ 'level-seed': '', 'level-name': 'Bedrock level' }) }; + const fs = { readFile: vi.fn().mockRejectedValue(new Error('ENOENT')) }; + const svc = createSeedService({ config, propertiesService, fs }); + expect(await svc.resolve({}, 'bedrock')).toBeNull(); + }); +}); diff --git a/test/services/stdinService.test.js b/test/services/stdinService.test.js new file mode 100644 index 0000000..c5e02f9 --- /dev/null +++ b/test/services/stdinService.test.js @@ -0,0 +1,113 @@ +import { describe, it, expect, vi } from 'vitest'; +import { PassThrough } from 'node:stream'; +import { createStdinService } from '../../src/services/stdinService.js'; + +describe('stdinService', () => { + it('writes command with newline and captures output window', async () => { + const stream = new PassThrough(); + const dockerService = { attach: vi.fn().mockResolvedValue(stream) }; + const svc = createStdinService(dockerService, { windowMs: 20 }); + + const p = svc.send('list'); + // simulate server echoing output + setTimeout(() => stream.write('There are 2 players online\n'), 5); + const out = await p; + + expect(out).toContain('2 players online'); + }); + + it('appends a newline to the written command', async () => { + const stream = new PassThrough(); + const written = []; + stream.write = (chunk) => { written.push(chunk.toString()); return true; }; + const dockerService = { attach: vi.fn().mockResolvedValue(stream) }; + const svc = createStdinService(dockerService, { windowMs: 5 }); + await svc.send('op steve'); + expect(written[0]).toBe('op steve\n'); + }); + + it('serializes concurrent sends (no interleaving)', async () => { + const stream = new PassThrough(); + const order = []; + // Track write order to prove serialization + const originalWrite = stream.write.bind(stream); + stream.write = (...args) => { + order.push(args[0].toString().trim()); + return originalWrite(...args); + }; + + + const dockerService = { attach: vi.fn().mockResolvedValue(stream) }; + const svc = createStdinService(dockerService, { windowMs: 40 }); + + // Fire 3 commands concurrently — they must NOT overlap + const p1 = svc.send('cmd1'); + const p2 = svc.send('cmd2'); + const p3 = svc.send('cmd3'); + + // Simulate server responses aimed at the MIDDLE of each serialized capture + // window ([0,40) / [40,80) / [80,120)ms) so timer jitter can't flake this. + setTimeout(() => stream.push('reply1\n'), 15); + setTimeout(() => stream.push('reply2\n'), 55); + setTimeout(() => stream.push('reply3\n'), 95); + + const [r1, r2, r3] = await Promise.all([p1, p2, p3]); + + // Commands must have been written in order (serialized) + expect(order).toEqual(['cmd1', 'cmd2', 'cmd3']); + // Each response captured in its own window + expect(r1).toContain('reply1'); + expect(r2).toContain('reply2'); + expect(r3).toContain('reply3'); + }); + + it('re-attaches after the stream ends (e.g. container stop/start on world import)', async () => { + const stream1 = new PassThrough(); + const stream2 = new PassThrough(); + let n = 0; + const dockerService = { + attach: vi.fn().mockImplementation(async () => (++n === 1 ? stream1 : stream2)), + }; + const svc = createStdinService(dockerService, { windowMs: 5 }); + + await svc.send('a'); + expect(dockerService.attach).toHaveBeenCalledTimes(1); + + // Container stopped: the attach stream ends. A stale, non-destroyed stream + // must NOT be reused — the next command has to re-attach. + stream1.emit('end'); + await new Promise((r) => setTimeout(r, 1)); + + setTimeout(() => stream2.push('ok\n'), 2); + const out = await svc.send('b'); + expect(dockerService.attach).toHaveBeenCalledTimes(2); + expect(out).toContain('ok'); + }); + + it('queue continues even if a command rejects (stream error)', async () => { + let callCount = 0; + const dockerService = { + attach: vi.fn().mockImplementation(async () => { + callCount++; + if (callCount === 1) { + // First call: return a stream that immediately errors on write + const bad = new PassThrough(); + bad.write = () => { throw new Error('broken pipe'); }; + return bad; + } + // Second call: return a healthy stream + const good = new PassThrough(); + setTimeout(() => good.push('ok\n'), 3); + return good; + }), + }; + const svc = createStdinService(dockerService, { windowMs: 10 }); + + // First send should reject + await expect(svc.send('bad')).rejects.toThrow('broken pipe'); + + // The service must recover (re-attach) and the queue must not be stuck + const result = await svc.send('good'); + expect(result).toContain('ok'); + }); +}); diff --git a/test/services/worldServiceBackup.test.js b/test/services/worldServiceBackup.test.js new file mode 100644 index 0000000..2bfb651 --- /dev/null +++ b/test/services/worldServiceBackup.test.js @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import fsp from 'node:fs/promises'; +import { createReadStream, createWriteStream } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import archiver from 'archiver'; +import unzipper from 'unzipper'; +import { createWorldService } from '../../src/services/worldService.js'; + +const extractZip = (zip, dest) => + new Promise((res, rej) => + createReadStream(zip).pipe(unzipper.Extract({ path: dest })).on('close', res).on('error', rej)); + +function makeService(mcDataPath) { + return createWorldService({ + config: { mcDataPath, mcWorldName: 'worlds' }, + dockerService: { getState: async () => 'exited', stop: async () => {}, start: async () => {} }, + propertiesService: { read: async () => ({ 'level-name': 'Bedrock level' }) }, + archiver, + extractZip, + }); +} + +async function mkWorld(dataPath, levelDatContent) { + const world = path.join(dataPath, 'worlds', 'Bedrock level'); + await fsp.mkdir(path.join(world, 'db'), { recursive: true }); + await fsp.writeFile(path.join(world, 'level.dat'), levelDatContent); + await fsp.writeFile(path.join(world, 'db', '000001.ldb'), 'blocks'); +} + +describe('world export/import bundles player data (full backup)', () => { + it('carries allowlist + history from one instance to another', async () => { + const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'craftdock-bk-')); + const src = path.join(tmp, 'src'); + const dst = path.join(tmp, 'dst'); + + // Source: a world + player-data files at the data root. + await mkWorld(src, 'SOURCE-WORLD'); + await fsp.writeFile(path.join(src, 'allowlist.json'), + JSON.stringify([{ name: 'Lucasrguerra', ignoresPlayerLimit: false }])); + await fsp.writeFile(path.join(src, 'permissions.json'), JSON.stringify([])); + await fsp.writeFile(path.join(src, 'craftdock_players_history.json'), JSON.stringify(['Lucasrguerra'])); + + // Export. + const zipPath = path.join(tmp, 'world.zip'); + const archive = await makeService(src).createDownloadStream(); + const out = createWriteStream(zipPath); + archive.pipe(out); + await new Promise((res, rej) => { out.on('close', res); archive.on('error', rej); }); + + // Destination: a different, older world and an empty allowlist. + await mkWorld(dst, 'OLD-WORLD'); + await fsp.writeFile(path.join(dst, 'allowlist.json'), JSON.stringify([])); + + // Import. + await makeService(dst).importWorld(zipPath); + + // World replaced. + expect(await fsp.readFile(path.join(dst, 'worlds', 'Bedrock level', 'level.dat'), 'utf8')) + .toBe('SOURCE-WORLD'); + // Allowlist migrated (Lucasrguerra now known on the destination). + const allow = JSON.parse(await fsp.readFile(path.join(dst, 'allowlist.json'), 'utf8')); + expect(allow.map((e) => e.name)).toContain('Lucasrguerra'); + // History migrated. + const hist = JSON.parse(await fsp.readFile(path.join(dst, 'craftdock_players_history.json'), 'utf8')); + expect(hist).toContain('Lucasrguerra'); + // permissions.json migrated too. + await expect(fsp.access(path.join(dst, 'permissions.json'))).resolves.toBeUndefined(); + }); +}); diff --git a/test/services/worldServiceDownload.test.js b/test/services/worldServiceDownload.test.js new file mode 100644 index 0000000..58c2dd3 --- /dev/null +++ b/test/services/worldServiceDownload.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect, vi } from 'vitest'; +import path from 'node:path'; +import { createWorldService } from '../../src/services/worldService.js'; + +describe('worldService.createDownloadStream', () => { + it('queues the world directory and finalizes the archive', async () => { + const archive = { directory: vi.fn(), file: vi.fn(), finalize: vi.fn(), on: vi.fn() }; + const archiver = vi.fn().mockReturnValue(archive); + const svc = createWorldService({ + config: { mcDataPath: '/data', mcWorldName: 'world' }, + dockerService: {}, + // no player-data files present -> access rejects, nothing extra is bundled + fs: { access: vi.fn().mockRejectedValue(new Error('ENOENT')) }, + archiver, + }); + const out = await svc.createDownloadStream(); + expect(archiver).toHaveBeenCalledWith('zip', expect.any(Object)); + expect(archive.directory).toHaveBeenCalledWith(path.join('/data', 'world'), 'world'); + expect(archive.finalize).toHaveBeenCalled(); + expect(out).toBe(archive); + }); + + it('bundles existing player-data files under craftdock/', async () => { + const archive = { directory: vi.fn(), file: vi.fn(), finalize: vi.fn(), on: vi.fn() }; + const archiver = vi.fn().mockReturnValue(archive); + const svc = createWorldService({ + config: { mcDataPath: '/data', mcWorldName: 'world' }, + dockerService: {}, + // only allowlist.json exists + fs: { access: vi.fn().mockImplementation(async (p) => { if (!p.endsWith('allowlist.json')) throw new Error('ENOENT'); }) }, + archiver, + }); + await svc.createDownloadStream(); + expect(archive.file).toHaveBeenCalledWith(path.join('/data', 'allowlist.json'), { name: 'craftdock/allowlist.json' }); + }); +}); diff --git a/test/services/worldServiceRegen.test.js b/test/services/worldServiceRegen.test.js new file mode 100644 index 0000000..7886f48 --- /dev/null +++ b/test/services/worldServiceRegen.test.js @@ -0,0 +1,46 @@ +import { describe, it, expect, vi } from 'vitest'; +import path from 'node:path'; +import { createWorldService } from '../../src/services/worldService.js'; + +function deps() { + return { + config: { mcDataPath: '/data', mcWorldName: 'world' }, + dockerService: { + getState: vi.fn().mockResolvedValue('running'), + stop: vi.fn().mockResolvedValue(), + start: vi.fn().mockResolvedValue(), + }, + propertiesService: { + update: vi.fn().mockResolvedValue({}), + }, + fs: { rm: vi.fn().mockResolvedValue() }, + }; +} + +describe('worldService.regen', () => { + it('stops, removes world dir, restarts', async () => { + const d = deps(); + const svc = createWorldService(d); + const res = await svc.regen(); + expect(d.dockerService.stop).toHaveBeenCalled(); + expect(d.fs.rm).toHaveBeenCalledWith(path.join('/data', 'world'), { recursive: true, force: true }); + expect(d.dockerService.start).toHaveBeenCalled(); + expect(res).toEqual({ ok: true }); + }); + + it('does not stop if already stopped', async () => { + const d = deps(); + d.dockerService.getState.mockResolvedValue('exited'); + const svc = createWorldService(d); + await svc.regen(); + expect(d.dockerService.stop).not.toHaveBeenCalled(); + expect(d.dockerService.start).toHaveBeenCalled(); + }); + + it('updates level-seed in propertiesService if seed is provided', async () => { + const d = deps(); + const svc = createWorldService(d); + await svc.regen('12345'); + expect(d.propertiesService.update).toHaveBeenCalledWith({ 'level-seed': '12345' }); + }); +}); diff --git a/test/services/worldServiceUpload.test.js b/test/services/worldServiceUpload.test.js new file mode 100644 index 0000000..70ad43d --- /dev/null +++ b/test/services/worldServiceUpload.test.js @@ -0,0 +1,93 @@ +import { describe, it, expect, vi } from 'vitest'; +import path from 'node:path'; +import { createWorldService } from '../../src/services/worldService.js'; + +function deps(extractOk = true) { + return { + config: { mcDataPath: '/data', mcWorldName: 'world' }, + dockerService: { + getState: vi.fn().mockResolvedValue('running'), + stop: vi.fn().mockResolvedValue(), + start: vi.fn().mockResolvedValue(), + }, + fs: { + rm: vi.fn().mockResolvedValue(), + rename: vi.fn().mockResolvedValue(), + mkdir: vi.fn().mockResolvedValue(), + readdir: vi.fn().mockResolvedValue(['level.dat']), + stat: vi.fn().mockResolvedValue({ isDirectory: () => false }), + }, + // extractZip is injected for testability + extractZip: vi.fn().mockImplementation(async () => { + if (!extractOk) throw new Error('bad zip'); + }), + }; +} + +describe('worldService.importWorld', () => { + it('extracts to temp, swaps, restarts on success', async () => { + const d = deps(true); + const svc = createWorldService(d); + const res = await svc.importWorld('/tmp/upload.zip'); + expect(d.extractZip).toHaveBeenCalledWith('/tmp/upload.zip', path.join('/data', 'world.import')); + expect(d.fs.rm).toHaveBeenCalledWith(path.join('/data', 'world'), { recursive: true, force: true }); + expect(d.fs.rename).toHaveBeenCalledWith(path.join('/data', 'world.import'), path.join('/data', 'world')); + expect(d.dockerService.start).toHaveBeenCalled(); + expect(res).toEqual({ ok: true }); + }); + + it('leaves live world intact and restarts server on bad zip', async () => { + const d = deps(false); + const svc = createWorldService(d); + await expect(svc.importWorld('/tmp/upload.zip')).rejects.toThrow('bad zip'); + // live world never removed + expect(d.fs.rm).not.toHaveBeenCalledWith(path.join('/data', 'world'), expect.anything()); + expect(d.fs.rename).not.toHaveBeenCalled(); + // temp cleaned + server restarted + expect(d.fs.rm).toHaveBeenCalledWith(path.join('/data', 'world.import'), { recursive: true, force: true }); + expect(d.dockerService.start).toHaveBeenCalled(); + }); + + it('hoists nested directory for Java', async () => { + const d = deps(true); + d.fs.readdir = vi.fn().mockImplementation(async (dir) => { + if (dir.endsWith('world.import')) { + return ['my_world']; + } + if (dir.endsWith('my_world') || dir.endsWith('aligned_world')) { + return ['level.dat', 'db']; + } + return []; + }); + d.fs.stat = vi.fn().mockImplementation(async (filePath) => { + return { isDirectory: () => filePath.endsWith('my_world') || filePath.endsWith('aligned_world') }; + }); + + const svc = createWorldService(d); + await svc.importWorld('/tmp/upload.zip'); + expect(d.fs.rename).toHaveBeenCalledWith(path.join('/data', 'world.import/my_world'), path.join('/data', 'world.import/aligned_world')); + expect(d.fs.rm).toHaveBeenCalledWith(path.join('/data', 'world.import/aligned_world'), { recursive: true, force: true }); + }); + + it('aligns directory to level-name for Bedrock flat zip', async () => { + const d = deps(true); + d.config.mcWorldName = 'worlds'; + d.propertiesService = { + read: vi.fn().mockResolvedValue({ 'level-name': 'My Bedrock World' }) + }; + d.fs.readdir = vi.fn().mockImplementation(async (dir) => { + if (dir.endsWith('worlds.import') || dir.endsWith('worlds.import.temp')) { + return ['level.dat', 'db']; + } + return []; + }); + d.fs.stat = vi.fn().mockResolvedValue({ isDirectory: () => false }); + + const svc = createWorldService(d); + await svc.importWorld('/tmp/upload.zip'); + expect(d.fs.mkdir).toHaveBeenCalledWith(path.join('/data', 'worlds.import/My Bedrock World'), { recursive: true }); + }); + + // Bundled player-data (craftdock/) migration is covered end-to-end in + // worldServiceBackup.test.js (real archiver + unzipper round-trip). +}); diff --git a/test/sockets/index.test.js b/test/sockets/index.test.js new file mode 100644 index 0000000..fe51870 --- /dev/null +++ b/test/sockets/index.test.js @@ -0,0 +1,22 @@ +import { describe, it, expect, vi } from 'vitest'; +import { registerSockets } from '../../src/sockets/index.js'; + +describe('registerSockets', () => { + it('creates /logs and /status namespaces with auth middleware', () => { + const namespaces = {}; + const io = { + of: vi.fn((name) => { + const ns = { use: vi.fn(), on: vi.fn() }; + namespaces[name] = ns; + return ns; + }), + }; + registerSockets(io, { + dockerService: {}, appState: {}, sessionMiddleware: (req, res, next) => next(), + }); + expect(io.of).toHaveBeenCalledWith('/logs'); + expect(io.of).toHaveBeenCalledWith('/status'); + expect(namespaces['/logs'].use).toHaveBeenCalled(); + expect(namespaces['/status'].use).toHaveBeenCalled(); + }); +}); diff --git a/test/sockets/logsSocket.test.js b/test/sockets/logsSocket.test.js new file mode 100644 index 0000000..db93e95 --- /dev/null +++ b/test/sockets/logsSocket.test.js @@ -0,0 +1,56 @@ +import { describe, it, expect, vi } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { registerLogsSocket } from '../../src/sockets/logsSocket.js'; + +function fakeNamespace() { + const ns = new EventEmitter(); + ns.on = ns.on.bind(ns); + return ns; +} +function fakeSocket() { + const s = new EventEmitter(); + s.emit = s.emit.bind(s); + s.emitted = []; + const origEmit = s.emit; + s.emit = (ev, payload) => { s.emitted.push([ev, payload]); return origEmit(ev, payload); }; + return s; +} + +describe('logsSocket', () => { + it('streams log chunks to the client', async () => { + const logStream = new PassThrough(); + const dockerService = { logStream: vi.fn().mockResolvedValue(logStream) }; + const appState = { getAdapter: vi.fn().mockResolvedValue({ sendCommand: vi.fn() }) }; + const ns = fakeNamespace(); + registerLogsSocket(ns, { dockerService, appState }); + + const socket = fakeSocket(); + ns.emit('connection', socket); + await new Promise((r) => setTimeout(r, 10)); // allow async logStream() + + logStream.write('hello world'); + await new Promise((r) => setTimeout(r, 5)); + + const logs = socket.emitted.filter(([ev]) => ev === 'log').map(([, p]) => p); + expect(logs.join('')).toContain('hello world'); + }); + + it('routes command events to adapter.sendCommand', async () => { + const logStream = new PassThrough(); + const sendCommand = vi.fn().mockResolvedValue('command result'); + const dockerService = { logStream: vi.fn().mockResolvedValue(logStream) }; + const appState = { getAdapter: vi.fn().mockResolvedValue({ sendCommand }) }; + const ns = fakeNamespace(); + registerLogsSocket(ns, { dockerService, appState }); + + const socket = fakeSocket(); + ns.emit('connection', socket); + await new Promise((r) => setTimeout(r, 10)); + + socket.emit('command', 'list'); + await new Promise((r) => setTimeout(r, 5)); + + expect(sendCommand).toHaveBeenCalledWith('list'); + }); +}); diff --git a/test/sockets/statusSocket.test.js b/test/sockets/statusSocket.test.js new file mode 100644 index 0000000..691027a --- /dev/null +++ b/test/sockets/statusSocket.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect, vi } from 'vitest'; +import { buildStatusPayload } from '../../src/sockets/statusSocket.js'; + +describe('buildStatusPayload', () => { + it('aggregates state, stats and players when running', async () => { + const dockerService = { + inspect: vi.fn().mockResolvedValue({ state: 'running', type: 'PAPER' }), + stats: vi.fn().mockResolvedValue({ cpuPct: 5, memUsedMb: 256, memPct: 25 }), + }; + const appState = { + getAdapter: vi.fn().mockResolvedValue({ + listPlayers: vi.fn().mockResolvedValue({ online: 2, max: 20, players: ['a', 'b'] }), + }), + }; + const payload = await buildStatusPayload({ dockerService, appState }); + expect(payload).toMatchObject({ + state: 'running', cpuPct: 5, memPct: 25, + players: { online: 2, max: 20, players: ['a', 'b'] }, + }); + }); + + it('returns zeros and empty players when not running', async () => { + const dockerService = { + inspect: vi.fn().mockResolvedValue({ state: 'exited', type: 'PAPER' }), + stats: vi.fn(), + }; + const appState = { getAdapter: vi.fn() }; + const payload = await buildStatusPayload({ dockerService, appState }); + expect(payload.state).toBe('exited'); + expect(payload.players).toEqual({ online: 0, max: 0, players: [] }); + expect(dockerService.stats).not.toHaveBeenCalled(); + }); +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..4ab473c --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['test/**/*.test.js'], + globals: false, + }, +});