From 7421214607dd3a9e3b57f33b2fc8f2a62829fd1a Mon Sep 17 00:00:00 2001 From: macery12 Date: Sat, 8 Aug 2026 14:53:13 -0500 Subject: [PATCH 1/5] feat: build secure community level sharing --- .gitignore | 15 +- BUILDING.md | 63 - README.md | 155 -- backend/.npmrc | 5 + backend/community-api/.dev.vars.example | 6 + .../community-api/migrations/0001_phase0.sql | 109 + .../0002_phase1_auth_and_limits.sql | 60 + backend/community-api/package.json | 26 + backend/community-api/src/auth.ts | 364 +++ backend/community-api/src/config.ts | 86 + backend/community-api/src/crypto.ts | 158 ++ backend/community-api/src/download-quota.ts | 98 + backend/community-api/src/downloads.ts | 169 ++ backend/community-api/src/http.ts | 110 + backend/community-api/src/local-fixture.ts | 157 ++ backend/community-api/src/maintenance.ts | 25 + backend/community-api/src/maps.ts | 303 +++ backend/community-api/src/rate-limit.ts | 43 + backend/community-api/src/steam-openid.ts | 326 +++ backend/community-api/src/worker.ts | 247 ++ .../community-api/test/apply-migrations.ts | 4 + backend/community-api/test/bundle.test.ts | 89 + backend/community-api/test/phase1.test.ts | 219 ++ backend/community-api/test/worker.test.ts | 260 ++ backend/community-api/tsconfig.json | 9 + backend/community-api/vitest.config.ts | 29 + .../community-api/worker-configuration.d.ts | 23 + backend/community-api/wrangler.jsonc | 62 + .../wrangler.production.example.jsonc | 73 + backend/community-contracts/package.json | 9 + backend/community-contracts/src/index.ts | 4 + .../community-contracts/src/level-bundle.ts | 533 ++++ backend/community-contracts/src/level-code.ts | 34 + backend/community-contracts/src/types.ts | 70 + backend/community-contracts/src/validation.ts | 82 + backend/community-contracts/tsconfig.json | 8 + backend/package.json | 14 + backend/pnpm-lock.yaml | 2269 +++++++++++++++++ backend/pnpm-workspace.yaml | 7 + backend/scripts/secret-scan.mjs | 42 + backend/tsconfig.base.json | 18 + catalog/catalog-v1.schema.json | 159 ++ catalog/catalog.json | 5 + src/SS2Revive/CommunityCatalogClient.cs | 462 ++++ src/SS2Revive/LevelFormatGuard.cs | 606 +++++ src/SS2Revive/LevelSharing.cs | 446 ++++ src/SS2Revive/PatchSet.cs | 21 + src/SS2Revive/Plugin.cs | 27 + src/SS2Revive/SS2Revive.csproj | 18 + src/SS2Revive/SharingPatches.cs | 443 ++++ src/SS2Revive/TerminalMessage.cs | 80 + src/SS2Revive/UgcBackend.cs | 73 +- src/SS2Revive/UgcPatches.cs | 6 + src/SS2Revive_Data/AtomicFile.cs | 10 +- src/SS2Revive_Data/CommunityCatalog.cs | 463 ++++ src/SS2Revive_Data/LevelBundle.cs | 838 ++++++ src/SS2Revive_Data/LevelCode.cs | 70 + src/SS2Revive_Data/UgcStore.cs | 431 +++- tests/DataTests/Program.cs | 385 +++ .../CatalogPublisher/CatalogPublisher.csproj | 24 + tools/CatalogPublisher/Program.cs | 702 +++++ 61 files changed, 11403 insertions(+), 249 deletions(-) delete mode 100644 BUILDING.md delete mode 100644 README.md create mode 100644 backend/.npmrc create mode 100644 backend/community-api/.dev.vars.example create mode 100644 backend/community-api/migrations/0001_phase0.sql create mode 100644 backend/community-api/migrations/0002_phase1_auth_and_limits.sql create mode 100644 backend/community-api/package.json create mode 100644 backend/community-api/src/auth.ts create mode 100644 backend/community-api/src/config.ts create mode 100644 backend/community-api/src/crypto.ts create mode 100644 backend/community-api/src/download-quota.ts create mode 100644 backend/community-api/src/downloads.ts create mode 100644 backend/community-api/src/http.ts create mode 100644 backend/community-api/src/local-fixture.ts create mode 100644 backend/community-api/src/maintenance.ts create mode 100644 backend/community-api/src/maps.ts create mode 100644 backend/community-api/src/rate-limit.ts create mode 100644 backend/community-api/src/steam-openid.ts create mode 100644 backend/community-api/src/worker.ts create mode 100644 backend/community-api/test/apply-migrations.ts create mode 100644 backend/community-api/test/bundle.test.ts create mode 100644 backend/community-api/test/phase1.test.ts create mode 100644 backend/community-api/test/worker.test.ts create mode 100644 backend/community-api/tsconfig.json create mode 100644 backend/community-api/vitest.config.ts create mode 100644 backend/community-api/worker-configuration.d.ts create mode 100644 backend/community-api/wrangler.jsonc create mode 100644 backend/community-api/wrangler.production.example.jsonc create mode 100644 backend/community-contracts/package.json create mode 100644 backend/community-contracts/src/index.ts create mode 100644 backend/community-contracts/src/level-bundle.ts create mode 100644 backend/community-contracts/src/level-code.ts create mode 100644 backend/community-contracts/src/types.ts create mode 100644 backend/community-contracts/src/validation.ts create mode 100644 backend/community-contracts/tsconfig.json create mode 100644 backend/package.json create mode 100644 backend/pnpm-lock.yaml create mode 100644 backend/pnpm-workspace.yaml create mode 100644 backend/scripts/secret-scan.mjs create mode 100644 backend/tsconfig.base.json create mode 100644 catalog/catalog-v1.schema.json create mode 100644 catalog/catalog.json create mode 100644 src/SS2Revive/CommunityCatalogClient.cs create mode 100644 src/SS2Revive/LevelFormatGuard.cs create mode 100644 src/SS2Revive/LevelSharing.cs create mode 100644 src/SS2Revive/SharingPatches.cs create mode 100644 src/SS2Revive/TerminalMessage.cs create mode 100644 src/SS2Revive_Data/CommunityCatalog.cs create mode 100644 src/SS2Revive_Data/LevelBundle.cs create mode 100644 src/SS2Revive_Data/LevelCode.cs create mode 100644 tools/CatalogPublisher/CatalogPublisher.csproj create mode 100644 tools/CatalogPublisher/Program.cs diff --git a/.gitignore b/.gitignore index fb208f9..c208032 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,9 @@ obj/ # Local machine paths live here and must never be published. Directory.Build.user.props -# Working notes and protocol write-ups. Kept on disk, not in the repository. +# Documentation is retained locally and transferred manually, not published from this branch. docs/ +*.md # BepInEx is linked from the README, never vendored here. lib/ @@ -25,3 +26,15 @@ dist/ # Inventory.dat, ProgressionConfig.json and the news tile artwork. *.dat assets/newsfeed/images/*.png + +# Local Cloudflare Worker development. Never commit local state or secrets. +backend/node_modules/ +backend/**/node_modules/ +backend/**/.wrangler/ +backend/**/.dev.vars +backend/**/.env* +backend/**/wrangler.production.jsonc +backend/**/coverage/ +backend/**/dist/ +backend/**/dist-production/ +.pnpm-store/ diff --git a/BUILDING.md b/BUILDING.md deleted file mode 100644 index db539f7..0000000 --- a/BUILDING.md +++ /dev/null @@ -1,63 +0,0 @@ -# Building SS2 Revive from source - -You need the [.NET SDK](https://dotnet.microsoft.com/download) (10 or newer) and a copy of the game -installed. BepInEx and HarmonyX come from NuGet, so there is nothing else to fetch. - -```powershell -dotnet build SS2Revive.sln -c Release -``` - -The build finds the game through Steam. If it cannot, because your library lives on another drive -or you run the game outside Steam, copy `Directory.Build.user.props.example` to -`Directory.Build.user.props` and set `GameDir` in it. That filename is gitignored. You can also -pass the path directly: - -```powershell -dotnet build SS2Revive.sln -c Release -p:GameDir="D:\SteamLibrary\steamapps\common\Surgeon Simulator 2" -``` - -A successful build copies both DLLs and the news feed straight into your `BepInEx\plugins\SS2Revive` -folder, so the edit-build-launch loop needs no copying by hand. - -There is a self-check that runs the backend against your installed game files: - -```powershell -dotnet run --project tests\DataTests -``` - -It prints a line per check and exits non-zero if any fail. Checks that need `Inventory.dat` report -`SKIP` rather than failing when no install is found. - -## Making a release - -```powershell -.\pack.ps1 -``` - -Builds, runs the self-check, and writes `dist\SS2Revive-.zip` containing exactly what the -install step in the README tells you to copy. It refuses to package anything if the self-check -fails. - -Attach that file to the release as it is. `installCurrentVersion.ps1` asks GitHub for the newest -release and takes the `SS2Revive-*.zip` on it, so the version in the name never has to be told to -anything - but the `SS2Revive-` prefix does have to stay. - -It also verifies that the `SS2Revive_Data.dll` going into the zip is the **net472** build. That -project multi-targets, the two outputs look interchangeable, and shipping the netstandard2.0 one -boots the game to a black screen with no main menu - the reason is at the top of -`src/SS2Revive_Data/SS2Revive_Data.csproj`. It is not a mistake you would catch by looking. - -The version comes from `SS2ReviveVersion` in `Directory.Build.props`, which is the only place it -is written down. Both assemblies and the `[BepInPlugin]` attribute are generated from it, so the -number the log prints and the number on the zip cannot drift apart. - -## Layout - -| Path | What it is | -|---|---| -| `src/SS2Revive` | The plugin. Harmony patches, Steam lobbies, Steam P2P transport, news feed. | -| `src/SS2Revive_Data` | The backend, as a plain library. Request router, save file, challenge catalogue, JSON.| -| `tests/DataTests` | A console runner for the library. No test framework, no packages. | -| `assets/newsfeed` | The authored news feed that ships with a release. | -| `installCurrentVersion.ps1` | Installs build 1.3.7, BepInEx and the mod, into a folder of its own. | -| `pack.ps1` | Builds, self-checks and assembles the release zip. | diff --git a/README.md b/README.md deleted file mode 100644 index 7be6ac9..0000000 --- a/README.md +++ /dev/null @@ -1,155 +0,0 @@ -# SS2 Revive - -Restores multiplayer, progression, and Creation Mode to **Surgeon Simulator 2** after Bossa's servers went offline. - -> **Not affiliated with Bossa Studios or Curve Games.** Unofficial fan mod. Ships no game code, no game assets. See [Disclaimer](#disclaimer). - -- **Requires build:** 1.3.7.3054, Windows 64-bit -- **1.5.x will NOT work** — the offline patch removed the netcode this mod restores. If Steam is serving you 1.5.x, see [Getting Build 1.3.7](#getting-build-137). - ---- - -## Quick Start - -**New install:** -```powershell -.\installCurrentVersion.ps1 -``` -Enter your Steam login when prompted — [DepotDownloader](https://github.com/SteamRE/DepotDownloader) handles the password/Guard code and fetches the game itself. This creates a **separate folder**; your normal Steam copy (1.5.x) is untouched. Launch with the generated `Launch Surgeon Simulator 2 - 1.3.7.cmd`. - -**Already have 1.3.7 installed?** Skip to [Install](#install), step 3. - ---- - -## What It Restores - -- **Sign-in & version check** — no longer blocks startup -- **Parties & invites** — via Steam lobbies -- **Multiplayer traffic** — peer-to-peer over Steam, not Bossa's relay -- **Progression** — daily challenges, campaign grades, cosmetics all served & saved locally -- **Creation Mode** — build, save, and playtest levels to your own disk -- **Main menu news tiles** — reads from a local, editable file - ---- - -## Requirements - -- Surgeon Simulator 2 on Steam, Windows 64-bit, **build 1.3.7** -- BepInEx 5.4.23.2, **x64** — [direct download](https://github.com/BepInEx/BepInEx/releases/download/v5.4.23.2/BepInEx_win_x64_5.4.23.2.zip) ([all files](https://github.com/BepInEx/BepInEx/releases/tag/v5.4.23.2)) -- Steam running and signed in - -> ⚠️ Get the **x64** build specifically — the 32-bit one installs without complaining, then loads nothing. - ---- - -## Getting Build 1.3.7 - -Steam serves 1.5.x by default, which won't work with this mod. - -- **New install** → run `installCurrentVersion.ps1` (see [Quick Start](#quick-start)) -- **Already have 1.3.7** → skip to [Install](#install), step 3 - -
-Why 1.5.x doesn't work / how the installer works - -1.5.x removed the netcode the mod attaches to. The mod will detect this, log it, and stop rather than half-working. - -`installCurrentVersion.ps1` does the whole install — game, BepInEx, and the mod. It asks where to install, then for the Steam login of the account that owns the game, and hands that off to DepotDownloader, which prompts for the password and Steam Guard code itself. **Nothing about your credentials is stored by this script** — it only fetches a build of a game the account already owns. - -The result is a separate, self-contained folder, not a change to your Steam copy. Steam keeps serving 1.5.x to your library and is unaware of this folder. The installer also writes `steam_appid.txt` — without it, `SteamAPI.Init` has no app ID to resolve outside a Steam launch, and the game stops at "Authentication Failed: Platform Authentication Error." - -Steam still needs to be running and signed in when you play — parties, invites, and P2P traffic all go through it. - -
- ---- - -## Install - -*Only needed if you skipped the installer, or it couldn't fetch a release (in which case steps 1–2 are already done).* - -1. **Install BepInEx.** Extract the zip into your game folder (the one with the `.exe` — usually `steamapps\common\Surgeon Simulator 2`; right-click the game in Steam → Manage → Browse local files). You should end up with `winhttp.dll`, `doorstop_config.ini`, and a `BepInEx\` folder next to the exe. -2. **Launch the game once through Steam, then quit.** This lets BepInEx create its `config`/`plugins` folders and a first `LogOutput.log`. -3. **Install the plugin.** Download the latest release and copy its contents into: - ``` - Surgeon Simulator 2\BepInEx\plugins\SS2Revive\ - ``` - You need `SS2Revive.dll`, `SS2Revive_Data.dll`, and the `newsfeed` folder — all together in that one folder. Both DLLs must sit side by side (BepInEx resolves a plugin's dependencies from its own folder). -4. **Launch the game.** Confirm it loaded by checking `BepInEx\LogOutput.log` for lines tagged `[Info :SS2 Revive]`. - ---- - -## Uninstall - -- **Mod only:** delete `BepInEx\plugins\SS2Revive\` -- **Mod + BepInEx:** also delete `winhttp.dll`, `doorstop_config.ini`, `.doorstop_version`, and the `BepInEx` folder - -Nothing in the game's own files is ever modified — a Steam file verification won't undo this and won't complain about it either. - ---- - -## Reporting a Bug - -- Open an issue and attach `Surgeon Simulator 2\BepInEx\LogOutput.log` from the run where it happened. Almost nothing can be diagnosed without it. -- Press **F9** in-game to write a state dump to that same log — do this for anything involving parties, progression, or cosmetics. -- The log includes your **SteamID64** (identifies your profile, can't be used to sign in as you). Redact it if you'd rather not have it public. - ---- - -## Where Your Progress Is Saved - -``` -%LOCALAPPDATA%\Bossa Studios\Surgeon Simulator 2\SS2Revive\progress.json -``` - -- Sits outside both the game and BepInEx directories, so verifying game files or reinstalling the mod can't delete it -- Plain JSON — override the location with `SaveDirectory` in the config -- Each save replaces the file in one atomic step and keeps the previous version as `progress.json.bak` — a power loss mid-write costs nothing. If `progress.json` gets damaged, delete it and rename `.bak` over it -- Creation Mode levels live in the `levels` folder next to it, one folder per level (metadata + revisions as readable JSON). Copy the folder to move a level to another machine - ---- - -## Configuration - -Config file appears after first run at `BepInEx\config\dev.ss2revive.core.cfg`. Defaults are the intended experience — the ones worth knowing about: - -| Setting | Default | What it does | -|---|---|---| -| `Bypass.ConnectionCheck` | `true` | Skips asking the shut-down server for permission to start. Disabling this on 1.3.7+ leaves you stuck at the "requires an active internet connection" box. | -| `Backend.Mode` | `Local` | Where the game's dead HTTP calls get answered. `Local` answers in-process. `Off` is diagnostic-only — disables progression/challenges/cosmetics rather than restoring them. | -| `Backend.GrantAllCosmetics` | `true` | Unlocks every cosmetic set. Set `false` to earn them via the reward track. | -| `Backend.SaveDirectory` | *(empty)* | Overrides where `progress.json` and levels are written. | -| `CreationMode.Enabled` | `true` | Saves built levels to this machine. Off = Creation Mode loads into a black screen (game won't open a level until it's "uploaded"). | -| `FreeForAll.Enabled` | `true` | Fills the Free-for-all queue from levels on this machine. Off = empty queue (Bossa served this from published community levels). | -| `FreeForAll.IncludeGameLevels` | `true` | Lets Free-for-all fall back to campaign levels when your library has nothing that fits. No FFA level ships with the game. | -| `Party.SteamP2PTransport` | `true` | Sends gameplay traffic over Steam peer-to-peer. | -| `Party.InviteKey` | `F10` | Opens the Steam invite overlay. | -| `Party.ShareLevelOverSteam` | `true` | Publishes your season level to lobby members/friends. | -| `NewsFeed.Enabled` | `true` | Points the main menu tiles at the local feed. | -| `Diagnostics.ProbeKey` | `F9` | Dumps current state to the log. | -| `Diagnostics.Verbose` | `true` | Includes live session/patient state in that dump — leave on for bug reports. | - ---- - -## Editing the News Feed - -Edit `BepInEx\plugins\SS2Revive\newsfeed\NewsFeed.json` and restart the game. - -- Each tile: title, subtitle, image filename, optional `ClickUrl` -- Images go in `newsfeed\images\`, **512×289 PNG** -- The game's own artwork is copied in on first run, so tiles are never blank to start (those images belong to Bossa and aren't distributed here) -- Building from source? Edit `assets\newsfeed\NewsFeed.json` instead — see [BUILDING.md](BUILDING.md) - ---- - -## Building From Source - -Covered in [BUILDING.md](BUILDING.md). - ---- - -## Disclaimer - -SS2 Revive is an unofficial, non-commercial fan modification. It is not affiliated with, endorsed by, sponsored by, or connected to Bossa Studios, Curve Games, or anyone else involved in making or publishing Surgeon Simulator 2. All trademarks and copyrights belong to their respective owners. - -The repository contains no game code and no game assets. The mod reads data from the copy of the game you already own, on your own machine, and modifies nothing the game installed. It exists so that a game people paid for keeps working after its servers were switched off. diff --git a/backend/.npmrc b/backend/.npmrc new file mode 100644 index 0000000..3bcafd1 --- /dev/null +++ b/backend/.npmrc @@ -0,0 +1,5 @@ +save-exact=true +strict-peer-dependencies=true +auto-install-peers=false +audit=true +fund=false diff --git a/backend/community-api/.dev.vars.example b/backend/community-api/.dev.vars.example new file mode 100644 index 0000000..6e30d98 --- /dev/null +++ b/backend/community-api/.dev.vars.example @@ -0,0 +1,6 @@ +# Copy this file to .dev.vars and replace the value with a base64-encoded 32-byte random secret. +# PowerShell example: +# $bytes = [byte[]]::new(32) +# [Security.Cryptography.RandomNumberGenerator]::Fill($bytes) +# [Convert]::ToBase64String($bytes) +LOCAL_AUTH_SECRET= diff --git a/backend/community-api/migrations/0001_phase0.sql b/backend/community-api/migrations/0001_phase0.sql new file mode 100644 index 0000000..16a1de8 --- /dev/null +++ b/backend/community-api/migrations/0001_phase0.sql @@ -0,0 +1,109 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE users ( + steam_id64 TEXT PRIMARY KEY NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'deleted')), + created_at INTEGER NOT NULL, + last_login_at INTEGER NOT NULL +) STRICT; + +CREATE TABLE auth_sessions ( + id TEXT PRIMARY KEY NOT NULL, + steam_id64 TEXT NOT NULL REFERENCES users(steam_id64), + status TEXT NOT NULL CHECK (status IN ('active', 'revoked')), + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + revoked_at INTEGER +) STRICT; +CREATE INDEX idx_auth_sessions_user_status ON auth_sessions(steam_id64, status); + +CREATE TABLE device_auth_sessions ( + id TEXT PRIMARY KEY NOT NULL, + device_secret_hash TEXT NOT NULL UNIQUE, + user_code_hash TEXT NOT NULL UNIQUE, + steam_id64 TEXT REFERENCES users(steam_id64), + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'denied', 'consumed')), + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + approved_at INTEGER, + consumed_at INTEGER +) STRICT; +CREATE INDEX idx_device_auth_expiry ON device_auth_sessions(status, expires_at); + +CREATE TABLE refresh_token_families ( + id TEXT PRIMARY KEY NOT NULL, + session_id TEXT NOT NULL REFERENCES auth_sessions(id), + steam_id64 TEXT NOT NULL REFERENCES users(steam_id64), + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + revoked_at INTEGER, + revoke_reason TEXT +) STRICT; +CREATE INDEX idx_refresh_families_session ON refresh_token_families(session_id); + +CREATE TABLE refresh_tokens ( + id TEXT PRIMARY KEY NOT NULL, + family_id TEXT NOT NULL REFERENCES refresh_token_families(id), + token_hash TEXT NOT NULL UNIQUE, + status TEXT NOT NULL CHECK (status IN ('active', 'rotated', 'revoked')), + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + used_at INTEGER, + rotated_to_id TEXT +) STRICT; +CREATE INDEX idx_refresh_tokens_family_status ON refresh_tokens(family_id, status); + +CREATE TABLE maps ( + id TEXT PRIMARY KEY NOT NULL, + status TEXT NOT NULL CHECK (status IN ('published', 'unpublished', 'archived')), + current_revision INTEGER NOT NULL CHECK (current_revision >= 1), + title TEXT NOT NULL, + title_sort TEXT NOT NULL, + description TEXT NOT NULL, + created_at_ms INTEGER NOT NULL CHECK (created_at_ms >= 0), + updated_at_ms INTEGER NOT NULL CHECK (updated_at_ms >= created_at_ms) +) STRICT; +CREATE INDEX idx_maps_published_updated ON maps(status, updated_at_ms DESC, id ASC); +CREATE INDEX idx_maps_published_created ON maps(status, created_at_ms DESC, id ASC); +CREATE INDEX idx_maps_published_title ON maps(status, title_sort ASC, id ASC); + +CREATE TABLE map_versions ( + map_id TEXT NOT NULL REFERENCES maps(id), + revision INTEGER NOT NULL CHECK (revision >= 1), + status TEXT NOT NULL CHECK (status IN ('published', 'unpublished', 'rejected')), + code TEXT NOT NULL, + creator_ids_json TEXT NOT NULL, + tags_json TEXT NOT NULL, + configurations_json TEXT NOT NULL, + validations_json TEXT NOT NULL, + player_counts_csv TEXT NOT NULL, + client_version INTEGER NOT NULL CHECK (client_version = 29), + map_format_version INTEGER NOT NULL CHECK (map_format_version = 29), + minimum_revive_version TEXT NOT NULL, + revive_version TEXT, + size_bytes INTEGER NOT NULL CHECK (size_bytes BETWEEN 1 AND 25165824), + sha256 TEXT NOT NULL CHECK (length(sha256) = 64 AND sha256 NOT GLOB '*[^0-9a-f]*'), + bundle_key TEXT NOT NULL UNIQUE, + thumbnail_size_bytes INTEGER, + thumbnail_sha256 TEXT, + thumbnail_key TEXT UNIQUE, + created_at_ms INTEGER NOT NULL CHECK (created_at_ms >= 0), + CHECK ( + (thumbnail_size_bytes IS NULL AND thumbnail_sha256 IS NULL AND thumbnail_key IS NULL) + OR + (thumbnail_size_bytes BETWEEN 1 AND 8388608 AND + thumbnail_sha256 IS NOT NULL AND length(thumbnail_sha256) = 64 AND + thumbnail_sha256 NOT GLOB '*[^0-9a-f]*' AND thumbnail_key IS NOT NULL) + ), + PRIMARY KEY (map_id, revision) +) STRICT; +CREATE INDEX idx_map_versions_published ON map_versions(map_id, status, revision DESC); + +CREATE TABLE map_tags ( + map_id TEXT NOT NULL, + revision INTEGER NOT NULL, + tag TEXT NOT NULL, + PRIMARY KEY (map_id, revision, tag), + FOREIGN KEY (map_id, revision) REFERENCES map_versions(map_id, revision) ON DELETE CASCADE +) STRICT; +CREATE INDEX idx_map_tags_lookup ON map_tags(tag, map_id, revision); diff --git a/backend/community-api/migrations/0002_phase1_auth_and_limits.sql b/backend/community-api/migrations/0002_phase1_auth_and_limits.sql new file mode 100644 index 0000000..1d8195e --- /dev/null +++ b/backend/community-api/migrations/0002_phase1_auth_and_limits.sql @@ -0,0 +1,60 @@ +ALTER TABLE auth_sessions ADD COLUMN source_device_session_id TEXT; +CREATE UNIQUE INDEX idx_auth_sessions_source_device + ON auth_sessions(source_device_session_id) + WHERE source_device_session_id IS NOT NULL; + +ALTER TABLE device_auth_sessions ADD COLUMN last_polled_at INTEGER; + +CREATE UNIQUE INDEX idx_refresh_family_one_active + ON refresh_tokens(family_id) + WHERE status = 'active'; + +CREATE TRIGGER trg_auth_session_requires_approved_device +BEFORE INSERT ON auth_sessions +WHEN NEW.source_device_session_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM device_auth_sessions d + WHERE d.id = NEW.source_device_session_id + AND d.status = 'approved' + AND d.expires_at > NEW.created_at + ) +BEGIN + SELECT RAISE(ABORT, 'device_session_not_approved'); +END; + +CREATE TABLE steam_openid_sessions ( + id TEXT PRIMARY KEY NOT NULL, + device_session_id TEXT NOT NULL UNIQUE REFERENCES device_auth_sessions(id) ON DELETE CASCADE, + state_hash TEXT NOT NULL UNIQUE, + status TEXT NOT NULL CHECK (status IN ('pending', 'verified', 'confirmed', 'failed')), + return_to TEXT NOT NULL, + steam_id64 TEXT, + response_nonce_hash TEXT UNIQUE, + confirm_token_hash TEXT, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + verified_at INTEGER, + confirmed_at INTEGER +) STRICT; +CREATE INDEX idx_steam_openid_expiry ON steam_openid_sessions(status, expires_at); + +CREATE TABLE download_usage_daily ( + steam_id64 TEXT NOT NULL REFERENCES users(steam_id64), + day_utc TEXT NOT NULL, + bytes_reserved INTEGER NOT NULL DEFAULT 0 CHECK (bytes_reserved >= 0), + download_starts INTEGER NOT NULL DEFAULT 0 CHECK (download_starts >= 0), + updated_at INTEGER NOT NULL, + PRIMARY KEY (steam_id64, day_utc) +) STRICT; + +CREATE TABLE download_leases ( + id TEXT PRIMARY KEY NOT NULL, + steam_id64 TEXT NOT NULL REFERENCES users(steam_id64), + day_utc TEXT NOT NULL, + bytes_reserved INTEGER NOT NULL CHECK (bytes_reserved > 0), + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +) STRICT; +CREATE INDEX idx_download_leases_actor_expiry + ON download_leases(steam_id64, expires_at); +CREATE INDEX idx_download_leases_expiry ON download_leases(expires_at); diff --git a/backend/community-api/package.json b/backend/community-api/package.json new file mode 100644 index 0000000..7706214 --- /dev/null +++ b/backend/community-api/package.json @@ -0,0 +1,26 @@ +{ + "name": "@ss2revive/community-api", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "wrangler deploy --dry-run --outdir dist", + "check": "tsc --noEmit", + "dev": "wrangler dev --local --ip 127.0.0.1", + "db:migrate:local": "wrangler d1 migrations apply ss2revive-community-local --local", + "test": "vitest run", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "@ss2revive/community-contracts": "workspace:*" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "0.20.3", + "@cloudflare/workers-types": "5.20260804.1", + "@types/node": "26.2.0", + "@vitest/coverage-istanbul": "4.1.10", + "typescript": "7.0.2", + "vitest": "4.1.10", + "wrangler": "4.120.0" + } +} diff --git a/backend/community-api/src/auth.ts b/backend/community-api/src/auth.ts new file mode 100644 index 0000000..724b83f --- /dev/null +++ b/backend/community-api/src/auth.ts @@ -0,0 +1,364 @@ +import { hasOnlyKeys, isSteamId64, type AuthPrincipal } from "@ss2revive/community-contracts"; +import type { RuntimeConfig } from "./config"; +import { issueAccessToken, opaqueHash, randomToken, verifyAccessToken } from "./crypto"; +import { emptyResponse, HttpError, jsonResponse, readBoundedBody, readJsonObject, responseHeaders } from "./http"; + +const DEVICE_SESSION_LIFETIME_MS = 10 * 60 * 1000; +const LOGIN_SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000; +const USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + +interface DeviceSessionRow { + id: string; + steam_id64: string | null; + status: "pending" | "approved" | "denied" | "consumed"; + expires_at: number; + last_polled_at: number | null; +} + +interface RefreshTokenRow { + id: string; + family_id: string; + status: "active" | "rotated" | "revoked"; + expires_at: number; + session_id: string; + steam_id64: string; + family_revoked_at: number | null; + session_status: "active" | "revoked"; + user_status: "active" | "suspended" | "deleted"; +} + +function userCode(): string { + const bytes = new Uint8Array(8); + crypto.getRandomValues(bytes); + const characters = [...bytes].map((value) => USER_CODE_ALPHABET[value % USER_CODE_ALPHABET.length]); + return `${characters.slice(0, 4).join("")}-${characters.slice(4).join("")}`; +} + +function tokenResponse( + accessToken: string, + accessExpiresAtMs: number, + refreshToken: string, + nowMs: number, +): Record { + return { + tokenType: "Bearer", + accessToken, + expiresInSeconds: Math.max(0, Math.floor((accessExpiresAtMs - nowMs) / 1000)), + refreshToken, + refreshExpiresInSeconds: Math.floor(LOGIN_SESSION_LIFETIME_MS / 1000), + scope: "maps:read maps:download", + }; +} + +export async function createDeviceSession( + request: Request, + env: Env, + config: RuntimeConfig, + requestId: string, + nowMs: number, +): Promise { + const body = await readJsonObject(request, 1024); + if (!hasOnlyKeys(body, []) || Object.keys(body).length !== 0) { + throw new HttpError(400, "invalid_request", "The request body must be an empty object."); + } + const id = crypto.randomUUID(); + const deviceSecret = randomToken(); + const code = userCode(); + const expiresAt = nowMs + DEVICE_SESSION_LIFETIME_MS; + await env.DB.prepare( + `INSERT INTO device_auth_sessions + (id, device_secret_hash, user_code_hash, status, created_at, expires_at) + VALUES (?, ?, ?, 'pending', ?, ?)`, + ).bind( + id, + await opaqueHash(config, "device-secret", deviceSecret), + await opaqueHash(config, "user-code", code), + nowMs, + expiresAt, + ).run(); + + return jsonResponse({ + deviceSessionId: id, + deviceSecret, + userCode: code, + activationPath: `/activate?user_code=${encodeURIComponent(code)}`, + expiresAt: new Date(expiresAt).toISOString(), + pollIntervalSeconds: 5, + }, 201, requestId); +} + +export async function pollDeviceSession( + request: Request, + env: Env, + config: RuntimeConfig, + requestId: string, + nowMs: number, +): Promise { + const body = await readJsonObject(request, 4096); + if (!hasOnlyKeys(body, ["deviceSessionId", "deviceSecret"]) || + typeof body.deviceSessionId !== "string" || typeof body.deviceSecret !== "string" || + !/^[0-9a-f-]{36}$/.test(body.deviceSessionId) || body.deviceSecret.length !== 43) { + throw new HttpError(400, "invalid_request", "The device-session credentials are malformed."); + } + const secretHash = await opaqueHash(config, "device-secret", body.deviceSecret); + const row = await env.DB.prepare( + `SELECT id, steam_id64, status, expires_at, last_polled_at + FROM device_auth_sessions WHERE id = ? AND device_secret_hash = ?`, + ).bind(body.deviceSessionId, secretHash).first(); + if (row === null) throw new HttpError(400, "invalid_request", "The device-session credentials are invalid."); + if (row.expires_at <= nowMs) throw new HttpError(410, "device_session_expired", "The device session expired."); + if (row.status === "denied") throw new HttpError(403, "device_session_denied", "The device session was denied."); + if (row.status === "consumed") throw new HttpError(409, "device_session_consumed", "The device session was already consumed."); + if (row.status === "pending") { + const allowed = await env.DB.prepare( + `UPDATE device_auth_sessions SET last_polled_at = ? + WHERE id = ? AND status = 'pending' + AND (last_polled_at IS NULL OR last_polled_at <= ?) + RETURNING id`, + ).bind(nowMs, row.id, nowMs - 5000).first<{ id: string }>(); + if (allowed === null) { + const retryAfter = Math.max(1, Math.ceil(((row.last_polled_at ?? nowMs) + 5000 - nowMs) / 1000)); + throw new HttpError(429, "authorization_slow_down", "Wait before polling this device session again.", { + "Retry-After": String(retryAfter), + }); + } + return jsonResponse( + { status: "pending", expiresAt: new Date(row.expires_at).toISOString() }, + 202, + requestId, + { "Retry-After": "5" }, + ); + } + + if (!isSteamId64(row.steam_id64)) { + throw new HttpError(409, "device_session_consumed", "The device session is no longer available."); + } + + const sessionId = crypto.randomUUID(); + const familyId = crypto.randomUUID(); + const refreshId = crypto.randomUUID(); + const refreshToken = randomToken(); + const refreshHash = await opaqueHash(config, "refresh-token", refreshToken); + const sessionExpiresAt = nowMs + LOGIN_SESSION_LIFETIME_MS; + try { + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO users (steam_id64, status, created_at, last_login_at) + VALUES (?, 'active', ?, ?) + ON CONFLICT(steam_id64) DO UPDATE SET last_login_at = excluded.last_login_at`, + ).bind(row.steam_id64, nowMs, nowMs), + env.DB.prepare( + `INSERT INTO auth_sessions + (id, steam_id64, status, created_at, expires_at, source_device_session_id) + VALUES (?, ?, 'active', ?, ?, ?)`, + ).bind(sessionId, row.steam_id64, nowMs, sessionExpiresAt, row.id), + env.DB.prepare( + `INSERT INTO refresh_token_families (id, session_id, steam_id64, created_at, expires_at) + VALUES (?, ?, ?, ?, ?)`, + ).bind(familyId, sessionId, row.steam_id64, nowMs, sessionExpiresAt), + env.DB.prepare( + `INSERT INTO refresh_tokens (id, family_id, token_hash, status, created_at, expires_at) + VALUES (?, ?, ?, 'active', ?, ?)`, + ).bind(refreshId, familyId, refreshHash, nowMs, sessionExpiresAt), + env.DB.prepare( + `UPDATE device_auth_sessions SET status = 'consumed', consumed_at = ? + WHERE id = ? AND device_secret_hash = ? AND status = 'approved' AND expires_at > ?`, + ).bind(nowMs, row.id, secretHash, nowMs), + ]); + } catch { + throw new HttpError(409, "device_session_consumed", "The device session is no longer available."); + } + const access = await issueAccessToken(config, row.steam_id64, sessionId, nowMs); + return jsonResponse(tokenResponse(access.token, access.expiresAtMs, refreshToken, nowMs), 200, requestId); +} + +async function refreshRow(env: Env, config: RuntimeConfig, refreshToken: string): Promise { + const hash = await opaqueHash(config, "refresh-token", refreshToken); + return env.DB.prepare( + `SELECT t.id, t.family_id, t.status, t.expires_at, + f.session_id, f.steam_id64, f.revoked_at AS family_revoked_at, + s.status AS session_status, u.status AS user_status + FROM refresh_tokens t + JOIN refresh_token_families f ON f.id = t.family_id + JOIN auth_sessions s ON s.id = f.session_id + JOIN users u ON u.steam_id64 = f.steam_id64 + WHERE t.token_hash = ?`, + ).bind(hash).first(); +} + +async function revokeFamily(env: Env, row: RefreshTokenRow, nowMs: number, reason: string): Promise { + await env.DB.batch([ + env.DB.prepare( + `UPDATE refresh_token_families SET revoked_at = COALESCE(revoked_at, ?), revoke_reason = ? WHERE id = ?`, + ).bind(nowMs, reason, row.family_id), + env.DB.prepare(`UPDATE refresh_tokens SET status = 'revoked' WHERE family_id = ?`).bind(row.family_id), + env.DB.prepare( + `UPDATE auth_sessions SET status = 'revoked', revoked_at = COALESCE(revoked_at, ?) WHERE id = ?`, + ).bind(nowMs, row.session_id), + ]); +} + +export async function refreshSession( + request: Request, + env: Env, + config: RuntimeConfig, + requestId: string, + nowMs: number, +): Promise { + const body = await readJsonObject(request, 4096); + if (!hasOnlyKeys(body, ["refreshToken"]) || typeof body.refreshToken !== "string" || body.refreshToken.length !== 43) { + throw new HttpError(400, "invalid_request", "The refresh token is malformed."); + } + const row = await refreshRow(env, config, body.refreshToken); + if (row === null) throw new HttpError(401, "token_invalid", "The refresh token is invalid."); + if (row.status !== "active") { + await revokeFamily(env, row, nowMs, "refresh_token_reuse"); + throw new HttpError(401, "token_invalid", "The refresh-token family was revoked."); + } + if (row.expires_at <= nowMs || row.family_revoked_at !== null || row.session_status !== "active") { + throw new HttpError(401, "token_invalid", "The refresh token is expired or revoked."); + } + if (row.user_status !== "active") throw new HttpError(403, "account_suspended", "The account is not active."); + + const nextId = crypto.randomUUID(); + const nextToken = randomToken(); + const nextHash = await opaqueHash(config, "refresh-token", nextToken); + let rotationResults: D1Result[]; + try { + rotationResults = await env.DB.batch([ + env.DB.prepare( + `UPDATE refresh_tokens SET status = 'rotated', used_at = ?, rotated_to_id = ? + WHERE id = ? AND status = 'active'`, + ).bind(nowMs, nextId, row.id), + env.DB.prepare( + `INSERT INTO refresh_tokens (id, family_id, token_hash, status, created_at, expires_at) + VALUES (?, ?, ?, 'active', ?, ?)`, + ).bind(nextId, row.family_id, nextHash, nowMs, row.expires_at), + ]); + } catch { + const current = await refreshRow(env, config, body.refreshToken); + if (current !== null && current.status !== "active") { + await revokeFamily(env, current, nowMs, "concurrent_refresh_reuse"); + throw new HttpError(401, "token_invalid", "The refresh-token family was revoked."); + } + throw new HttpError(503, "temporarily_unavailable", "The session could not be refreshed.", { "Retry-After": "5" }); + } + if ((rotationResults[0]?.meta.changes ?? 0) !== 1) { + await revokeFamily(env, row, nowMs, "concurrent_refresh_reuse"); + throw new HttpError(401, "token_invalid", "The refresh-token family was revoked."); + } + const access = await issueAccessToken(config, row.steam_id64, row.session_id, nowMs); + return jsonResponse(tokenResponse(access.token, access.expiresAtMs, nextToken, nowMs), 200, requestId); +} + +export async function logoutSession( + request: Request, + env: Env, + config: RuntimeConfig, + requestId: string, + nowMs: number, +): Promise { + const body = await readJsonObject(request, 4096); + if (!hasOnlyKeys(body, ["refreshToken"]) || typeof body.refreshToken !== "string") { + throw new HttpError(400, "invalid_request", "The refresh token is malformed."); + } + const row = body.refreshToken.length === 43 ? await refreshRow(env, config, body.refreshToken) : null; + if (row !== null) await revokeFamily(env, row, nowMs, "logout"); + return emptyResponse(204, requestId); +} + +export async function authenticate( + request: Request, + env: Env, + config: RuntimeConfig, + requiredScope: "maps:read" | "maps:download", + nowMs: number, +): Promise { + const authorization = request.headers.get("Authorization"); + if (authorization === null || !authorization.startsWith("Bearer ")) { + throw new HttpError(401, "auth_required", "A bearer access token is required.", { + "WWW-Authenticate": 'Bearer realm="ss2revive-community"', + }); + } + const token = authorization.slice(7); + if (token.length < 32 || token.length > 4096) { + throw new HttpError(401, "token_invalid", "The access token is invalid."); + } + const principal = await verifyAccessToken(config, token, requiredScope, nowMs); + const row = await env.DB.prepare( + `SELECT s.status AS session_status, s.expires_at, u.status AS user_status + FROM auth_sessions s JOIN users u ON u.steam_id64 = s.steam_id64 + WHERE s.id = ? AND s.steam_id64 = ?`, + ).bind(principal.sessionId, principal.steamId64).first<{ + session_status: "active" | "revoked"; + expires_at: number; + user_status: "active" | "suspended" | "deleted"; + }>(); + if (row === null || row.session_status !== "active" || row.expires_at <= nowMs) { + throw new HttpError(401, "token_invalid", "The access session is expired or revoked."); + } + if (row.user_status !== "active") throw new HttpError(403, "account_suspended", "The account is not active."); + return principal; +} + +export async function approveLocalDeviceByCode( + env: Env, + config: RuntimeConfig, + code: string, + nowMs: number, +): Promise { + if (!config.allowMockAuth || !isSteamId64(config.mockSteamId64) || !/^[A-Z2-9]{4}-[A-Z2-9]{4}$/.test(code)) { + return false; + } + const codeHash = await opaqueHash(config, "user-code", code); + await env.DB.prepare( + `INSERT INTO users (steam_id64, status, created_at, last_login_at) + VALUES (?, 'active', ?, ?) + ON CONFLICT(steam_id64) DO NOTHING`, + ).bind(config.mockSteamId64, nowMs, nowMs).run(); + const updated = await env.DB.prepare( + `UPDATE device_auth_sessions + SET status = 'approved', steam_id64 = ?, approved_at = ? + WHERE user_code_hash = ? AND status = 'pending' AND expires_at > ? + RETURNING id`, + ).bind(config.mockSteamId64, nowMs, codeHash, nowMs).first<{ id: string }>(); + return updated !== null; +} + +function escapeHtml(value: string): string { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +} + +export async function localActivationPage( + request: Request, + env: Env, + config: RuntimeConfig, + requestId: string, + nowMs: number, +): Promise { + if (!config.allowMockAuth) throw new HttpError(404, "not_found", "The route was not found."); + let code = new URL(request.url).searchParams.get("user_code")?.toUpperCase() ?? ""; + let message = "This page is available only in local development."; + if (request.method === "POST") { + const contentType = request.headers.get("Content-Type")?.split(";", 1)[0]?.trim().toLowerCase(); + if (contentType !== "application/x-www-form-urlencoded") { + throw new HttpError(415, "invalid_request", "The activation form has an invalid content type."); + } + const body = await readBoundedBody(request, 4096); + code = new URLSearchParams(new TextDecoder().decode(body)).get("user_code")?.toUpperCase() ?? ""; + message = await approveLocalDeviceByCode(env, config, code, nowMs) + ? `Approved local Steam user ${config.mockSteamId64}. Return to the client.` + : "The code is invalid, expired, or already used."; + } else if (request.method !== "GET") { + throw new HttpError(405, "method_not_allowed", "The method is not allowed.", { Allow: "GET, POST" }); + } + const html = `SS2Revive local activation +

SS2Revive local activation

${escapeHtml(message)}

+
+
`; + const headers = responseHeaders(requestId, { + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'", + }); + return new Response(html, { status: 200, headers }); +} diff --git a/backend/community-api/src/config.ts b/backend/community-api/src/config.ts new file mode 100644 index 0000000..3038072 --- /dev/null +++ b/backend/community-api/src/config.ts @@ -0,0 +1,86 @@ +import { isSteamId64 } from "@ss2revive/community-contracts"; +import { HttpError } from "./http"; + +export interface RuntimeConfig { + environment: "local" | "staging" | "production"; + allowMockAuth: boolean; + issuer: string; + audience: string; + authSecret: string; + mockSteamId64: string; + publicOrigin: string; + downloadDailyBytes: number; + downloadConcurrency: number; +} + +function isBase64Secret(value: unknown): value is string { + if (typeof value !== "string" || !/^[A-Za-z0-9+/]{43}=$/u.test(value)) return false; + try { + return atob(value).length === 32; + } catch { + return false; + } +} + +function boundedInteger(value: unknown, minimum: number, maximum: number): number | null { + if (typeof value !== "string" || !/^[1-9][0-9]*$/u.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum ? parsed : null; +} + +function validPublicOrigin(value: unknown, environment: RuntimeConfig["environment"]): value is string { + if (typeof value !== "string" || value.length > 256) return false; + try { + const url = new URL(value); + if (url.origin !== value || url.username !== "" || url.password !== "") return false; + if (environment === "local") { + return url.protocol === "http:" && + (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]"); + } + return url.protocol === "https:"; + } catch { + return false; + } +} + +export function runtimeConfig(env: Env): RuntimeConfig { + if (env.ENVIRONMENT !== "local" && env.ENVIRONMENT !== "staging" && env.ENVIRONMENT !== "production") { + throw new HttpError(503, "temporarily_unavailable", "The service configuration is invalid."); + } + const authSecret = env.ENVIRONMENT === "local" ? env.LOCAL_AUTH_SECRET : env.AUTH_SIGNING_SECRET; + if (!isBase64Secret(authSecret)) { + throw new HttpError(503, "temporarily_unavailable", "The authentication signing secret is not configured."); + } + if ( + typeof env.AUTH_ISSUER !== "string" || typeof env.AUTH_AUDIENCE !== "string" || + env.AUTH_ISSUER.length < 1 || env.AUTH_ISSUER.length > 256 || + env.AUTH_AUDIENCE.length < 1 || env.AUTH_AUDIENCE.length > 128 || + /[\u0000-\u001f\u007f-\u009f]/u.test(env.AUTH_ISSUER) || + /[\u0000-\u001f\u007f-\u009f]/u.test(env.AUTH_AUDIENCE) + ) { + throw new HttpError(503, "temporarily_unavailable", "The authentication token configuration is invalid."); + } + const allowMockAuth = env.ENVIRONMENT === "local" && env.ALLOW_MOCK_AUTH === "true"; + if (allowMockAuth && !isSteamId64(env.MOCK_STEAM_ID64)) { + throw new HttpError(503, "temporarily_unavailable", "The local Steam identity is invalid."); + } + if (!validPublicOrigin(env.PUBLIC_ORIGIN, env.ENVIRONMENT)) { + throw new HttpError(503, "temporarily_unavailable", "The public origin configuration is invalid."); + } + const downloadDailyBytes = boundedInteger(env.DOWNLOAD_DAILY_BYTES, 1024 * 1024, 100 * 1024 * 1024 * 1024); + const downloadConcurrency = boundedInteger(env.DOWNLOAD_CONCURRENCY, 1, 10); + if (downloadDailyBytes === null || downloadConcurrency === null) { + throw new HttpError(503, "temporarily_unavailable", "The download quota configuration is invalid."); + } + return { + environment: env.ENVIRONMENT, + allowMockAuth, + issuer: env.AUTH_ISSUER, + audience: env.AUTH_AUDIENCE, + authSecret, + mockSteamId64: env.MOCK_STEAM_ID64, + publicOrigin: env.PUBLIC_ORIGIN, + downloadDailyBytes, + downloadConcurrency, + }; +} diff --git a/backend/community-api/src/crypto.ts b/backend/community-api/src/crypto.ts new file mode 100644 index 0000000..b82be0d --- /dev/null +++ b/backend/community-api/src/crypto.ts @@ -0,0 +1,158 @@ +import type { AuthPrincipal } from "@ss2revive/community-contracts"; +import { isPlainObject, isSteamId64 } from "@ss2revive/community-contracts"; +import type { RuntimeConfig } from "./config"; +import { HttpError } from "./http"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder("utf-8", { fatal: true }); + +function base64UrlEncode(bytes: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, Math.min(bytes.length, offset + 0x8000))); + } + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, ""); +} + +function base64UrlDecode(value: string): Uint8Array | null { + if (!/^[A-Za-z0-9_-]+$/.test(value)) return null; + const padding = "=".repeat((4 - (value.length % 4)) % 4); + try { + const binary = atob(value.replaceAll("-", "+").replaceAll("_", "/") + padding); + const decoded = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + return base64UrlEncode(decoded) === value ? decoded : null; + } catch { + return null; + } +} + +async function hmac(secret: string, value: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + return new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(value))); +} + +function constantTimeEqual(left: Uint8Array, right: Uint8Array): boolean { + let difference = left.length ^ right.length; + const maximum = Math.max(left.length, right.length); + for (let index = 0; index < maximum; index += 1) { + difference |= (left[index] ?? 0) ^ (right[index] ?? 0); + } + return difference === 0; +} + +export function randomToken(bytes = 32): string { + const value = new Uint8Array(bytes); + crypto.getRandomValues(value); + return base64UrlEncode(value); +} + +export async function opaqueHash(config: RuntimeConfig, context: string, value: string): Promise { + return base64UrlEncode(await hmac(config.authSecret, `${context}\u0000${value}`)); +} + +interface AccessClaims { + iss: string; + aud: string; + sub: string; + sid: string; + scope: string; + iat: number; + exp: number; +} + +export async function issueAccessToken( + config: RuntimeConfig, + steamId64: string, + sessionId: string, + nowMs: number, +): Promise<{ token: string; expiresAtMs: number }> { + const header = base64UrlEncode(encoder.encode(JSON.stringify({ alg: "HS256", typ: "JWT" }))); + const expiresAtMs = nowMs + 15 * 60 * 1000; + const claims: AccessClaims = { + iss: config.issuer, + aud: config.audience, + sub: steamId64, + sid: sessionId, + scope: "maps:read maps:download", + iat: Math.floor(nowMs / 1000), + exp: Math.floor(expiresAtMs / 1000), + }; + const payload = base64UrlEncode(encoder.encode(JSON.stringify(claims))); + const signature = base64UrlEncode(await hmac(config.authSecret, `${header}.${payload}`)); + return { token: `${header}.${payload}.${signature}`, expiresAtMs }; +} + +export async function verifyAccessToken( + config: RuntimeConfig, + token: string, + requiredScope: string, + nowMs: number, +): Promise { + const parts = token.split("."); + if (parts.length !== 3) throw new HttpError(401, "token_invalid", "The access token is invalid."); + const [headerText, payloadText, signatureText] = parts as [string, string, string]; + const signature = base64UrlDecode(signatureText); + const expected = await hmac(config.authSecret, `${headerText}.${payloadText}`); + if (signature === null || !constantTimeEqual(signature, expected)) { + throw new HttpError(401, "token_invalid", "The access token is invalid."); + } + const headerBytes = base64UrlDecode(headerText); + const payloadBytes = base64UrlDecode(payloadText); + if (headerBytes === null || payloadBytes === null) { + throw new HttpError(401, "token_invalid", "The access token is invalid."); + } + let header: unknown; + let claims: unknown; + try { + header = JSON.parse(decoder.decode(headerBytes)) as unknown; + claims = JSON.parse(decoder.decode(payloadBytes)) as unknown; + } catch { + throw new HttpError(401, "token_invalid", "The access token is invalid."); + } + if (!isPlainObject(header) || header.alg !== "HS256" || header.typ !== "JWT" || !isPlainObject(claims)) { + throw new HttpError(401, "token_invalid", "The access token is invalid."); + } + if ( + claims.iss !== config.issuer || claims.aud !== config.audience || !isSteamId64(claims.sub) || + typeof claims.sid !== "string" || !/^[0-9a-f-]{36}$/.test(claims.sid) || + typeof claims.scope !== "string" || !Number.isSafeInteger(claims.iat) || !Number.isSafeInteger(claims.exp) || + (claims.exp as number) <= Math.floor(nowMs / 1000) || (claims.iat as number) > Math.floor(nowMs / 1000) + 60 + ) { + throw new HttpError(401, "token_invalid", "The access token is invalid or expired."); + } + const scopes = claims.scope.split(" ").filter(Boolean); + if (!scopes.includes(requiredScope)) throw new HttpError(403, "scope_denied", "The token lacks the required scope."); + return { steamId64: claims.sub, sessionId: claims.sid, scopes }; +} + +export async function signedValue(config: RuntimeConfig, context: string, payload: unknown): Promise { + const body = base64UrlEncode(encoder.encode(JSON.stringify(payload))); + const signature = base64UrlEncode(await hmac(config.authSecret, `${context}\u0000${body}`)); + return `${body}.${signature}`; +} + +export async function verifySignedValue( + config: RuntimeConfig, + context: string, + value: string, +): Promise | null> { + const separator = value.lastIndexOf("."); + if (separator < 1) return null; + const body = value.slice(0, separator); + const supplied = base64UrlDecode(value.slice(separator + 1)); + const expected = await hmac(config.authSecret, `${context}\u0000${body}`); + const decoded = base64UrlDecode(body); + if (supplied === null || decoded === null || !constantTimeEqual(supplied, expected)) return null; + try { + const result = JSON.parse(decoder.decode(decoded)) as unknown; + return isPlainObject(result) ? result : null; + } catch { + return null; + } +} diff --git a/backend/community-api/src/download-quota.ts b/backend/community-api/src/download-quota.ts new file mode 100644 index 0000000..4ae0772 --- /dev/null +++ b/backend/community-api/src/download-quota.ts @@ -0,0 +1,98 @@ +import type { RuntimeConfig } from "./config"; +import { HttpError } from "./http"; + +const LEASE_LIFETIME_MS = 15 * 60 * 1000; + +export interface DownloadLease { + id: string; + steamId64: string; + dayUtc: string; + bytes: number; +} + +function utcDay(nowMs: number): string { + return new Date(nowMs).toISOString().slice(0, 10); +} + +export async function reserveDownload( + env: Env, + config: RuntimeConfig, + steamId64: string, + bytes: number, + nowMs: number, +): Promise { + if (!Number.isSafeInteger(bytes) || bytes < 1 || bytes > config.downloadDailyBytes) { + throw new HttpError(429, "quota_exceeded", "The daily download quota is exhausted.", { + "Retry-After": "3600", + }); + } + const id = crypto.randomUUID(); + const dayUtc = utcDay(nowMs); + const results = await env.DB.batch([ + env.DB.prepare( + `INSERT INTO download_usage_daily + (steam_id64, day_utc, bytes_reserved, download_starts, updated_at) + VALUES (?, ?, 0, 0, ?) + ON CONFLICT(steam_id64, day_utc) DO NOTHING`, + ).bind(steamId64, dayUtc, nowMs), + env.DB.prepare( + `INSERT INTO download_leases + (id, steam_id64, day_utc, bytes_reserved, created_at, expires_at) + SELECT ?, ?, ?, ?, ?, ? + WHERE (SELECT bytes_reserved FROM download_usage_daily + WHERE steam_id64 = ? AND day_utc = ?) <= ? - ? + AND (SELECT COUNT(*) FROM download_leases + WHERE steam_id64 = ? AND expires_at > ?) < ? + RETURNING id`, + ).bind( + id, steamId64, dayUtc, bytes, nowMs, nowMs + LEASE_LIFETIME_MS, + steamId64, dayUtc, config.downloadDailyBytes, bytes, + steamId64, nowMs, config.downloadConcurrency, + ), + env.DB.prepare( + `UPDATE download_usage_daily + SET bytes_reserved = bytes_reserved + ?, download_starts = download_starts + 1, + updated_at = ? + WHERE steam_id64 = ? AND day_utc = ? + AND EXISTS (SELECT 1 FROM download_leases WHERE id = ?)`, + ).bind(bytes, nowMs, steamId64, dayUtc, id), + ]); + const inserted = results[1]?.results as Array<{ id?: unknown }> | undefined; + if (inserted?.[0]?.id === id) return { id, steamId64, dayUtc, bytes }; + + const state = await env.DB.prepare( + `SELECT u.bytes_reserved, + (SELECT COUNT(*) FROM download_leases l + WHERE l.steam_id64 = u.steam_id64 AND l.expires_at > ?) AS active_leases + FROM download_usage_daily u WHERE u.steam_id64 = ? AND u.day_utc = ?`, + ).bind(nowMs, steamId64, dayUtc).first<{ bytes_reserved: number; active_leases: number }>(); + if (state !== null && state.active_leases >= config.downloadConcurrency) { + throw new HttpError(429, "download_concurrency_exceeded", "Too many downloads are already active.", { + "Retry-After": "30", + }); + } + throw new HttpError(429, "quota_exceeded", "The daily download quota is exhausted.", { + "Retry-After": "3600", + }); +} + +export async function completeDownload(env: Env, lease: DownloadLease): Promise { + await env.DB.prepare(`DELETE FROM download_leases WHERE id = ? AND steam_id64 = ?`) + .bind(lease.id, lease.steamId64).run(); +} + +export async function refundDownload(env: Env, lease: DownloadLease, nowMs: number): Promise { + await env.DB.batch([ + env.DB.prepare( + `UPDATE download_usage_daily + SET bytes_reserved = MAX(0, bytes_reserved - + COALESCE((SELECT bytes_reserved FROM download_leases WHERE id = ?), 0)), + download_starts = MAX(0, download_starts - + CASE WHEN EXISTS (SELECT 1 FROM download_leases WHERE id = ?) THEN 1 ELSE 0 END), + updated_at = ? + WHERE steam_id64 = ? AND day_utc = ?`, + ).bind(lease.id, lease.id, nowMs, lease.steamId64, lease.dayUtc), + env.DB.prepare(`DELETE FROM download_leases WHERE id = ? AND steam_id64 = ?`) + .bind(lease.id, lease.steamId64), + ]); +} diff --git a/backend/community-api/src/downloads.ts b/backend/community-api/src/downloads.ts new file mode 100644 index 0000000..27516c0 --- /dev/null +++ b/backend/community-api/src/downloads.ts @@ -0,0 +1,169 @@ +import { isSha256, MAX_BUNDLE_BYTES, MAX_IMAGE_BYTES } from "@ss2revive/community-contracts"; +import type { RuntimeConfig } from "./config"; +import { completeDownload, reserveDownload } from "./download-quota"; +import { emptyResponse, HttpError, responseHeaders } from "./http"; +import { downloadableVersion, type MapRow } from "./maps"; + +interface ByteRange { + offset: number; + length: number; +} + +function etagMatches(header: string | null, etag: string): boolean { + if (header === null) return false; + return header.split(",").map((value) => value.trim()).some((value) => value === "*" || value === etag); +} + +function parseRange(header: string, total: number): ByteRange | null { + if (!header.startsWith("bytes=") || header.includes(",")) return null; + const value = header.slice(6); + const match = /^(\d*)-(\d*)$/.exec(value); + if (match === null) return null; + const startText = match[1] ?? ""; + const endText = match[2] ?? ""; + if (startText === "" && endText === "") return null; + if (startText === "") { + if (!/^[1-9][0-9]*$/.test(endText)) return null; + const suffix = Number(endText); + if (!Number.isSafeInteger(suffix) || suffix < 1) return null; + const length = Math.min(suffix, total); + return { offset: total - length, length }; + } + if (!/^(?:0|[1-9][0-9]*)$/.test(startText)) return null; + const start = Number(startText); + if (!Number.isSafeInteger(start) || start >= total) return null; + if (endText === "") return { offset: start, length: total - start }; + if (!/^(?:0|[1-9][0-9]*)$/.test(endText)) return null; + const requestedEnd = Number(endText); + if (!Number.isSafeInteger(requestedEnd) || requestedEnd < start) return null; + const end = Math.min(requestedEnd, total - 1); + return { offset: start, length: end - start + 1 }; +} + +function objectMetadata(row: MapRow, kind: "bundle" | "thumbnail"): { + key: string; + size: number; + sha256: string; + contentType: string; + disposition?: string; +} { + if (kind === "bundle") { + return { + key: row.bundle_key, + size: row.size_bytes, + sha256: row.sha256, + contentType: "application/vnd.ss2revive.level", + disposition: `attachment; filename="map-${row.id}-r${row.revision}.ss2level"`, + }; + } + if (row.thumbnail_key === null || row.thumbnail_size_bytes === null || row.thumbnail_sha256 === null) { + throw new HttpError(404, "revision_not_found", "The map thumbnail was not found."); + } + return { + key: row.thumbnail_key, + size: row.thumbnail_size_bytes, + sha256: row.thumbnail_sha256, + contentType: "application/octet-stream", + }; +} + +export async function downloadObject( + request: Request, + env: Env, + config: RuntimeConfig, + steamId64: string, + mapId: string, + revision: number, + kind: "bundle" | "thumbnail", + requestId: string, + nowMs: number, +): Promise { + if (request.method !== "GET" && request.method !== "HEAD") { + throw new HttpError(405, "method_not_allowed", "The method is not allowed.", { Allow: "GET, HEAD" }); + } + const row = await downloadableVersion(env, mapId, revision); + const metadata = objectMetadata(row, kind); + const maximumSize = kind === "bundle" ? MAX_BUNDLE_BYTES : MAX_IMAGE_BYTES; + if (metadata.size < 1 || metadata.size > maximumSize || !isSha256(metadata.sha256) || metadata.key.length > 512) { + throw new HttpError(503, "object_metadata_mismatch", "The stored object metadata is invalid.", { "Retry-After": "30" }); + } + const etag = `"sha256-${metadata.sha256}"`; + if (request.headers.has("If-Match") && !etagMatches(request.headers.get("If-Match"), etag)) { + throw new HttpError(412, "etag_mismatch", "The requested object version does not match."); + } + + const rangeHeader = request.headers.get("Range"); + if (etagMatches(request.headers.get("If-None-Match"), etag)) { + return emptyResponse(304, requestId, { ETag: etag }); + } + const head = await env.MAP_BUCKET.head(metadata.key); + if (head === null || head.size !== metadata.size || head.customMetadata?.sha256 !== metadata.sha256) { + throw new HttpError(503, "object_metadata_mismatch", "The stored object failed its metadata check.", { "Retry-After": "30" }); + } + + let range: ByteRange | null = null; + if (rangeHeader !== null && (request.headers.get("If-Range") === null || request.headers.get("If-Range") === etag)) { + range = parseRange(rangeHeader, metadata.size); + if (range === null) { + throw new HttpError(416, "range_not_satisfiable", "The byte range is not satisfiable.", { + "Content-Range": `bytes */${metadata.size}`, + }); + } + } + + const status = range === null ? 200 : 206; + const contentLength = range?.length ?? metadata.size; + const headers = responseHeaders(requestId, { + "Accept-Ranges": "bytes", + "Cache-Control": "private, max-age=31536000, immutable", + "Content-Length": String(contentLength), + "Content-Type": metadata.contentType, + ETag: etag, + "X-Content-SHA256": metadata.sha256, + }); + if (metadata.disposition !== undefined) headers.set("Content-Disposition", metadata.disposition); + if (range !== null) { + headers.set("Content-Range", `bytes ${range.offset}-${range.offset + range.length - 1}/${metadata.size}`); + } + if (request.method === "HEAD") return new Response(null, { status, headers }); + + const object = await env.MAP_BUCKET.get( + metadata.key, + range === null ? undefined : { range: { offset: range.offset, length: range.length } }, + ); + if (object === null || object.size !== metadata.size || object.customMetadata?.sha256 !== metadata.sha256) { + throw new HttpError(503, "object_metadata_mismatch", "The stored object failed its metadata check.", { "Retry-After": "30" }); + } + const lease = await reserveDownload(env, config, steamId64, contentLength, nowMs); + const reader = object.body.getReader(); + let settled = false; + const settle = async (): Promise => { + if (settled) return; + settled = true; + await completeDownload(env, lease); + }; + const body = new ReadableStream({ + async pull(controller) { + try { + const result = await reader.read(); + if (result.done) { + await settle(); + controller.close(); + } else { + controller.enqueue(result.value); + } + } catch (error) { + controller.error(error); + await settle(); + } + }, + async cancel(reason) { + try { + await reader.cancel(reason); + } finally { + await settle(); + } + }, + }); + return new Response(body, { status, headers }); +} diff --git a/backend/community-api/src/http.ts b/backend/community-api/src/http.ts new file mode 100644 index 0000000..79bd4d7 --- /dev/null +++ b/backend/community-api/src/http.ts @@ -0,0 +1,110 @@ +import type { ApiErrorBody } from "@ss2revive/community-contracts"; + +export class HttpError extends Error { + readonly status: number; + readonly code: string; + readonly headers: HeadersInit | undefined; + + constructor(status: number, code: string, message: string, headers?: HeadersInit) { + super(message); + this.name = "HttpError"; + this.status = status; + this.code = code; + this.headers = headers; + } +} + +const BASE_HEADERS: Record = { + "Cache-Control": "no-store", + "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'; base-uri 'none'", + "Referrer-Policy": "no-referrer", + "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=()", + "Cross-Origin-Opener-Policy": "same-origin", + "X-Permitted-Cross-Domain-Policies": "none", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", +}; + +export function responseHeaders(requestId: string, extra?: HeadersInit): Headers { + const headers = new Headers(BASE_HEADERS); + headers.set("X-Request-Id", requestId); + if (extra !== undefined) { + const supplied = new Headers(extra); + supplied.forEach((value, key) => headers.set(key, value)); + } + return headers; +} + +export function jsonResponse(value: unknown, status: number, requestId: string, extra?: HeadersInit): Response { + const text = JSON.stringify(value); + const headers = responseHeaders(requestId, extra); + headers.set("Content-Type", "application/json; charset=utf-8"); + return new Response(text, { status, headers }); +} + +export function emptyResponse(status: number, requestId: string, extra?: HeadersInit): Response { + return new Response(null, { status, headers: responseHeaders(requestId, extra) }); +} + +export function errorResponse(error: HttpError, requestId: string): Response { + const body: ApiErrorBody = { error: { code: error.code, message: error.message, requestId } }; + return jsonResponse(body, error.status, requestId, error.headers); +} + +export async function readBoundedBody(request: Request, maximumBytes: number): Promise { + const declared = request.headers.get("Content-Length"); + if (declared !== null) { + if (!/^(?:0|[1-9][0-9]*)$/.test(declared) || Number(declared) > maximumBytes) { + throw new HttpError(413, "invalid_request", "The request body is too large."); + } + } + if (request.body === null) return new Uint8Array(); + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const result = await reader.read(); + if (result.done) break; + total += result.value.byteLength; + if (total > maximumBytes) { + await reader.cancel("body limit exceeded"); + throw new HttpError(413, "invalid_request", "The request body is too large."); + } + chunks.push(result.value); + } + } finally { + reader.releaseLock(); + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} + +export async function readJsonObject(request: Request, maximumBytes = 16 * 1024): Promise> { + const contentType = request.headers.get("Content-Type")?.split(";", 1)[0]?.trim().toLowerCase(); + if (contentType !== "application/json") { + throw new HttpError(415, "invalid_request", "Content-Type must be application/json."); + } + const bytes = await readBoundedBody(request, maximumBytes); + let value: unknown; + try { + value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown; + } catch { + throw new HttpError(400, "invalid_request", "The JSON body is invalid."); + } + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new HttpError(400, "invalid_request", "The JSON body must be an object."); + } + return value as Record; +} + +export function requireMethod(request: Request, method: string): void { + if (request.method !== method) { + throw new HttpError(405, "method_not_allowed", "The method is not allowed.", { Allow: method }); + } +} diff --git a/backend/community-api/src/local-fixture.ts b/backend/community-api/src/local-fixture.ts new file mode 100644 index 0000000..f771906 --- /dev/null +++ b/backend/community-api/src/local-fixture.ts @@ -0,0 +1,157 @@ +import { inspectLevelBundle, sha256Hex } from "@ss2revive/community-contracts"; +import type { RuntimeConfig } from "./config"; +import { HttpError } from "./http"; + +const FIXTURE_ID = "1a658233-92c5-4b63-87fc-4740c855730b"; +const FIXTURE_CREATED_AT = Date.UTC(2026, 0, 1, 0, 0, 0); +const encoder = new TextEncoder(); + +function writeUint16(output: number[], value: number): void { + output.push(value & 0xff, (value >>> 8) & 0xff); +} + +function writeInt32(output: number[], value: number): void { + output.push(value & 0xff, (value >>> 8) & 0xff, (value >>> 16) & 0xff, (value >>> 24) & 0xff); +} + +function writeEntry(output: number[], name: string, payload: Uint8Array): void { + const nameBytes = encoder.encode(name); + writeUint16(output, nameBytes.length); + output.push(...nameBytes); + writeInt32(output, payload.length); + output.push(...payload); +} + +function writeBits(bytes: Uint8Array, start: number, value: number, count: number): number { + for (let bit = 0; bit < count; bit += 1) { + if ((Math.floor(value / 2 ** bit) & 1) !== 0) { + const absolute = start + bit; + bytes[Math.floor(absolute / 8)]! |= 1 << (absolute % 8); + } + } + return start + count; +} + +function fixtureThumbnail(): Uint8Array { + const bytes = new Uint8Array(17); + let bit = 0; + bit = writeBits(bytes, bit, 1, 31); + bit = writeBits(bytes, bit, 1, 31); + bit = writeBits(bytes, bit, 4, 4); + writeBits(bytes, bit, 4, 31); + bytes.set([0x33, 0x66, 0x99, 0xff], 13); + return bytes; +} + +export async function buildLocalFixture(): Promise<{ + bytes: Uint8Array; + thumbnail: Uint8Array; + bundleKey: string; + thumbnailKey: string; + sha256: string; + thumbnailSha256: string; +}> { + const content = new Uint8Array(18); + content.set(encoder.encode("Surgeons"), 0); + content[8] = 29; + content[9] = 0; + content.set(encoder.encode("phase0!!"), 10); + const thumbnail = fixtureThumbnail(); + const contentSha256 = await sha256Hex(content); + const manifest = encoder.encode(JSON.stringify({ + bundleVersion: 2, + id: FIXTURE_ID, + title: "Phase 0 Local Test Room", + description: "A deterministic local-only backend fixture.", + createdAt: FIXTURE_CREATED_AT, + exportedAt: FIXTURE_CREATED_AT + 1000, + clientVersion: 29, + contentVersion: 1, + reviveVersion: "0.1.0", + contentSha256, + creators: ["STEAM-76561198145479980"], + tags: ["TEAM_COOP", "LOCAL_TEST"], + configurations: [{ numberPlayers: 1, numberTeams: 1, levelTeamConfigurations: [] }], + validations: [{ description: "Local deterministic fixture" }], + })); + const output = [...encoder.encode("SS2REVIVE LEVEL\n")]; + writeUint16(output, 2); + writeEntry(output, "asset.json", manifest); + writeEntry(output, "level.bin", content); + writeEntry(output, "thumb.png", thumbnail); + writeUint16(output, 0); + const bytes = new Uint8Array(output); + const inspected = await inspectLevelBundle(bytes, { nowMs: Date.UTC(2026, 7, 8) }); + const thumbnailSha256 = await sha256Hex(thumbnail); + return { + bytes, + thumbnail, + sha256: inspected.bundleSha256, + thumbnailSha256, + bundleKey: `approved/maps/${FIXTURE_ID}/r1/${inspected.bundleSha256}.ss2level`, + thumbnailKey: `approved/thumbnails/${FIXTURE_ID}/r1/${thumbnailSha256}.bin`, + }; +} + +export async function seedLocalFixture(env: Env, config: RuntimeConfig): Promise<{ id: string; revision: number }> { + if (!config.allowMockAuth) throw new HttpError(404, "not_found", "The route was not found."); + const fixture = await buildLocalFixture(); + await env.MAP_BUCKET.put(fixture.bundleKey, fixture.bytes, { + httpMetadata: { contentType: "application/vnd.ss2revive.level" }, + customMetadata: { sha256: fixture.sha256 }, + }); + await env.MAP_BUCKET.put(fixture.thumbnailKey, fixture.thumbnail, { + httpMetadata: { contentType: "application/octet-stream" }, + customMetadata: { sha256: fixture.thumbnailSha256 }, + }); + + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO maps + (id, status, current_revision, title, title_sort, description, created_at_ms, updated_at_ms) + VALUES (?, 'published', 1, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + status = excluded.status, current_revision = excluded.current_revision, + title = excluded.title, title_sort = excluded.title_sort, description = excluded.description, + created_at_ms = excluded.created_at_ms, updated_at_ms = excluded.updated_at_ms`, + ).bind( + FIXTURE_ID, + "Phase 0 Local Test Room", + "phase 0 local test room", + "A deterministic local-only backend fixture.", + FIXTURE_CREATED_AT, + FIXTURE_CREATED_AT + 1000, + ), + env.DB.prepare(`DELETE FROM map_tags WHERE map_id = ? AND revision = 1`).bind(FIXTURE_ID), + env.DB.prepare(`DELETE FROM map_versions WHERE map_id = ? AND revision = 1`).bind(FIXTURE_ID), + env.DB.prepare( + `INSERT INTO map_versions + (map_id, revision, status, code, creator_ids_json, tags_json, + configurations_json, validations_json, player_counts_csv, + client_version, map_format_version, minimum_revive_version, revive_version, + size_bytes, sha256, bundle_key, + thumbnail_size_bytes, thumbnail_sha256, thumbnail_key, created_at_ms) + VALUES (?, 1, 'published', ?, ?, ?, ?, ?, ?, 29, 29, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).bind( + FIXTURE_ID, + "M4JlGsWSY0uH_EdAyFVzCw", + JSON.stringify(["STEAM-76561198145479980"]), + JSON.stringify(["TEAM_COOP", "LOCAL_TEST"]), + JSON.stringify([{ numberPlayers: 1, numberTeams: 1, levelTeamConfigurations: [] }]), + JSON.stringify([{ description: "Local deterministic fixture" }]), + ",1,", + "0.1.0", + "0.1.0", + fixture.bytes.length, + fixture.sha256, + fixture.bundleKey, + fixture.thumbnail.length, + fixture.thumbnailSha256, + fixture.thumbnailKey, + FIXTURE_CREATED_AT + 1000, + ), + env.DB.prepare(`INSERT INTO map_tags (map_id, revision, tag) VALUES (?, 1, ?)`).bind(FIXTURE_ID, "TEAM_COOP"), + env.DB.prepare(`INSERT INTO map_tags (map_id, revision, tag) VALUES (?, 1, ?)`).bind(FIXTURE_ID, "LOCAL_TEST"), + ]); + return { id: FIXTURE_ID, revision: 1 }; +} diff --git a/backend/community-api/src/maintenance.ts b/backend/community-api/src/maintenance.ts new file mode 100644 index 0000000..e6a9e93 --- /dev/null +++ b/backend/community-api/src/maintenance.ts @@ -0,0 +1,25 @@ +export async function cleanupExpiredState(env: Env, nowMs: number): Promise { + const retentionCutoff = nowMs - 24 * 60 * 60 * 1000; + await env.DB.batch([ + env.DB.prepare(`DELETE FROM download_leases WHERE expires_at <= ?`).bind(nowMs), + env.DB.prepare(`DELETE FROM steam_openid_sessions WHERE expires_at <= ?`).bind(retentionCutoff), + env.DB.prepare(`DELETE FROM refresh_tokens WHERE expires_at <= ?`).bind(retentionCutoff), + env.DB.prepare( + `DELETE FROM refresh_token_families + WHERE expires_at <= ? AND NOT EXISTS + (SELECT 1 FROM refresh_tokens t WHERE t.family_id = refresh_token_families.id)`, + ).bind(retentionCutoff), + env.DB.prepare( + `DELETE FROM auth_sessions + WHERE expires_at <= ? AND NOT EXISTS + (SELECT 1 FROM refresh_token_families f WHERE f.session_id = auth_sessions.id)`, + ).bind(retentionCutoff), + env.DB.prepare( + `DELETE FROM device_auth_sessions + WHERE expires_at <= ? AND NOT EXISTS + (SELECT 1 FROM steam_openid_sessions o WHERE o.device_session_id = device_auth_sessions.id)`, + ).bind(retentionCutoff), + env.DB.prepare(`DELETE FROM download_usage_daily WHERE day_utc < ?`) + .bind(new Date(nowMs - 8 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)), + ]); +} diff --git a/backend/community-api/src/maps.ts b/backend/community-api/src/maps.ts new file mode 100644 index 0000000..4d03f8d --- /dev/null +++ b/backend/community-api/src/maps.ts @@ -0,0 +1,303 @@ +import { + isCanonicalUuid, + levelCodeFromId, + isSemanticVersion, + isSha256, + MAX_BUNDLE_BYTES, + MAX_DESCRIPTION_CHARACTERS, + MAX_IMAGE_BYTES, + MAX_PAGE_SIZE, + MAX_TITLE_CHARACTERS, + parseBoundedPositiveInteger, + sha256Hex, + type CommunityMap, + type MapPage, +} from "@ss2revive/community-contracts"; +import type { RuntimeConfig } from "./config"; +import { signedValue, verifySignedValue } from "./crypto"; +import { HttpError, jsonResponse } from "./http"; + +type MapSort = "updated_desc" | "created_desc" | "title_asc"; + +interface MapRow { + id: string; + revision: number; + title: string; + title_sort: string; + description: string; + created_at_ms: number; + updated_at_ms: number; + code: string; + creator_ids_json: string; + tags_json: string; + configurations_json: string; + validations_json: string; + client_version: number; + map_format_version: number; + minimum_revive_version: string; + revive_version: string | null; + size_bytes: number; + sha256: string; + bundle_key: string; + thumbnail_size_bytes: number | null; + thumbnail_sha256: string | null; + thumbnail_key: string | null; + version_created_at_ms: number; +} + +const MAP_SELECT = ` + SELECT m.id, v.revision, m.title, m.title_sort, m.description, + m.created_at_ms, m.updated_at_ms, v.code, + v.creator_ids_json, v.tags_json, v.configurations_json, v.validations_json, + v.client_version, v.map_format_version, v.minimum_revive_version, v.revive_version, + v.size_bytes, v.sha256, v.bundle_key, + v.thumbnail_size_bytes, v.thumbnail_sha256, v.thumbnail_key, + v.created_at_ms AS version_created_at_ms + FROM maps m + JOIN map_versions v ON v.map_id = m.id`; + +function parseArray(text: string, maximum: number, field: string): unknown[] { + let value: unknown; + try { + value = JSON.parse(text) as unknown; + } catch { + throw new HttpError(503, "object_metadata_mismatch", `Stored ${field} metadata is invalid.`); + } + if (!Array.isArray(value) || value.length > maximum) { + throw new HttpError(503, "object_metadata_mismatch", `Stored ${field} metadata is invalid.`); + } + return value; +} + +function stringArray(text: string, maximum: number, field: string): string[] { + const value = parseArray(text, maximum, field); + if (!value.every((item) => typeof item === "string" && item.length >= 1 && item.length <= 128)) { + throw new HttpError(503, "object_metadata_mismatch", `Stored ${field} metadata is invalid.`); + } + return value as string[]; +} + +function mapDto(row: MapRow): CommunityMap { + if ( + !isCanonicalUuid(row.id) || row.revision < 1 || !Number.isSafeInteger(row.revision) || + row.title.length < 1 || row.title.length > MAX_TITLE_CHARACTERS || + row.description.length > MAX_DESCRIPTION_CHARACTERS || + row.code !== levelCodeFromId(row.id) || + !Number.isSafeInteger(row.created_at_ms) || !Number.isSafeInteger(row.updated_at_ms) || + row.created_at_ms < 0 || row.updated_at_ms < row.created_at_ms || + row.client_version !== 29 || row.map_format_version !== 29 || + !isSemanticVersion(row.minimum_revive_version) || + (row.revive_version !== null && !isSemanticVersion(row.revive_version)) || + row.size_bytes < 1 || row.size_bytes > MAX_BUNDLE_BYTES || !isSha256(row.sha256) + ) { + throw new HttpError(503, "object_metadata_mismatch", "Stored map metadata is invalid."); + } + const expectedBundleKey = `approved/maps/${row.id}/r${row.revision}/${row.sha256}.ss2level`; + if (row.bundle_key !== expectedBundleKey) { + throw new HttpError(503, "object_metadata_mismatch", "Stored bundle metadata is invalid."); + } + const map: CommunityMap = { + id: row.id, + code: row.code, + revision: row.revision, + title: row.title, + description: row.description, + creatorIds: stringArray(row.creator_ids_json, 4, "creator"), + tags: stringArray(row.tags_json, 32, "tag"), + createdAtMs: row.created_at_ms, + updatedAtMs: row.updated_at_ms, + clientVersion: 29, + mapFormatVersion: 29, + minimumReviveVersion: row.minimum_revive_version, + sizeBytes: row.size_bytes, + sha256: row.sha256, + configurations: parseArray(row.configurations_json, 8, "configuration"), + validations: parseArray(row.validations_json, 32, "validation"), + downloadUrl: `/v1/maps/${row.id}/versions/${row.revision}/download`, + }; + if (row.revive_version !== null) map.reviveVersion = row.revive_version; + const thumbnailCount = Number(row.thumbnail_key !== null) + Number(row.thumbnail_sha256 !== null) + + Number(row.thumbnail_size_bytes !== null); + if (thumbnailCount !== 0 && thumbnailCount !== 3) { + throw new HttpError(503, "object_metadata_mismatch", "Stored thumbnail metadata is incomplete."); + } + if (row.thumbnail_key !== null && row.thumbnail_sha256 !== null && row.thumbnail_size_bytes !== null) { + const expectedThumbnailKey = `approved/thumbnails/${row.id}/r${row.revision}/${row.thumbnail_sha256}.bin`; + if ( + !isSha256(row.thumbnail_sha256) || row.thumbnail_size_bytes < 1 || + row.thumbnail_size_bytes > MAX_IMAGE_BYTES || row.thumbnail_key !== expectedThumbnailKey + ) { + throw new HttpError(503, "object_metadata_mismatch", "Stored thumbnail metadata is invalid."); + } + map.thumbnail = { + url: `/v1/maps/${row.id}/versions/${row.revision}/thumbnail`, + sizeBytes: row.thumbnail_size_bytes, + sha256: row.thumbnail_sha256, + }; + } + return map; +} + +function boundedSearch(value: string | null, name: string, maximum: number): string { + if (value === null) return ""; + if (value.length > maximum || /[\u0000-\u001f\u007f-\u009f]/u.test(value)) { + throw new HttpError(400, "invalid_request", `${name} is invalid.`); + } + return value.trim(); +} + +function escapeLike(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_"); +} + +async function filterHash(query: string, tag: string, players: number | null, sort: MapSort): Promise { + return sha256Hex(new TextEncoder().encode(JSON.stringify({ query, tag, players, sort }))); +} + +export async function listMaps( + request: Request, + env: Env, + config: RuntimeConfig, + requestId: string, + nowMs: number, +): Promise { + const url = new URL(request.url); + const limit = parseBoundedPositiveInteger(url.searchParams.get("limit"), 20, MAX_PAGE_SIZE); + if (limit === null) throw new HttpError(400, "invalid_request", "limit must be between 1 and 50."); + const query = boundedSearch(url.searchParams.get("query"), "query", 128); + const tag = boundedSearch(url.searchParams.get("tag"), "tag", 128); + if (tag !== "" && !/^[A-Za-z0-9_.-]+$/.test(tag)) throw new HttpError(400, "invalid_request", "tag is invalid."); + const playersText = url.searchParams.get("players"); + const players = playersText === null ? null : parseBoundedPositiveInteger(playersText, 1, 4); + if (playersText !== null && players === null) throw new HttpError(400, "invalid_request", "players must be between 1 and 4."); + const sortText = url.searchParams.get("sort") ?? "updated_desc"; + if (sortText !== "updated_desc" && sortText !== "created_desc" && sortText !== "title_asc") { + throw new HttpError(400, "unsupported_sort", "sort must be updated_desc, created_desc, or title_asc."); + } + const sort: MapSort = sortText; + const expectedFilterHash = await filterHash(query, tag, players, sort); + + const clauses = [ + "m.status = 'published'", + "v.status = 'published'", + "v.revision = m.current_revision", + "v.client_version = 29", + "v.map_format_version = 29", + ]; + const values: unknown[] = []; + if (query !== "") { + clauses.push("(v.code = ? OR m.title_sort LIKE ? ESCAPE '\\')"); + values.push(query, `${escapeLike(query.toLocaleLowerCase("en-US"))}%`); + } + if (tag !== "") { + clauses.push("EXISTS (SELECT 1 FROM map_tags mt WHERE mt.map_id = m.id AND mt.revision = v.revision AND mt.tag = ?)"); + values.push(tag); + } + if (players !== null) { + clauses.push("instr(v.player_counts_csv, ?) > 0"); + values.push(`,${players},`); + } + + const cursorText = url.searchParams.get("cursor"); + if (cursorText !== null) { + if (cursorText.length > 2048) throw new HttpError(400, "invalid_cursor", "The cursor is invalid."); + const cursor = await verifySignedValue(config, "map-cursor", cursorText); + if ( + cursor === null || cursor.v !== 1 || cursor.sort !== sort || cursor.filterHash !== expectedFilterHash || + typeof cursor.id !== "string" || !isCanonicalUuid(cursor.id) || + typeof cursor.iat !== "number" || !Number.isSafeInteger(cursor.iat) || + cursor.iat < nowMs - 60 * 60 * 1000 || cursor.iat > nowMs + 60_000 + ) { + throw new HttpError(400, "invalid_cursor", "The cursor is invalid or does not match these filters."); + } + if (sort === "title_asc") { + if (typeof cursor.value !== "string" || cursor.value.length > MAX_TITLE_CHARACTERS) { + throw new HttpError(400, "invalid_cursor", "The cursor is invalid."); + } + clauses.push("(m.title_sort > ? OR (m.title_sort = ? AND m.id > ?))"); + values.push(cursor.value, cursor.value, cursor.id); + } else { + if (typeof cursor.value !== "number" || !Number.isSafeInteger(cursor.value)) { + throw new HttpError(400, "invalid_cursor", "The cursor is invalid."); + } + const column = sort === "updated_desc" ? "m.updated_at_ms" : "m.created_at_ms"; + clauses.push(`(${column} < ? OR (${column} = ? AND m.id > ?))`); + values.push(cursor.value, cursor.value, cursor.id); + } + } + + const order = sort === "updated_desc" ? "m.updated_at_ms DESC, m.id ASC" + : sort === "created_desc" ? "m.created_at_ms DESC, m.id ASC" + : "m.title_sort ASC, m.id ASC"; + const result = await env.DB.prepare( + `${MAP_SELECT} WHERE ${clauses.join(" AND ")} ORDER BY ${order} LIMIT ?`, + ).bind(...values, limit + 1).all(); + const rows = result.results; + const hasMore = rows.length > limit; + const selected = rows.slice(0, limit); + let nextCursor: string | null = null; + const last = selected.at(-1); + if (hasMore && last !== undefined) { + const value = sort === "updated_desc" ? last.updated_at_ms + : sort === "created_desc" ? last.created_at_ms + : last.title_sort; + nextCursor = await signedValue(config, "map-cursor", { + v: 1, + sort, + filterHash: expectedFilterHash, + value, + id: last.id, + iat: nowMs, + }); + } + const page: MapPage & { requestId: string } = { requestId, items: selected.map(mapDto), nextCursor }; + const serialized = JSON.stringify(page); + if (new TextEncoder().encode(serialized).length > 1024 * 1024) { + throw new HttpError(503, "temporarily_unavailable", "The bounded map response could not be produced."); + } + return jsonResponse(page, 200, requestId); +} + +async function publishedMapRow(env: Env, mapId: string, revision?: number): Promise { + const revisionClause = revision === undefined ? "v.revision = m.current_revision" : "v.revision = ?"; + const statement = env.DB.prepare( + `${MAP_SELECT} + WHERE m.id = ? AND m.status = 'published' AND v.status = 'published' + AND ${revisionClause} AND v.client_version = 29 AND v.map_format_version = 29`, + ); + return revision === undefined + ? statement.bind(mapId).first() + : statement.bind(mapId, revision).first(); +} + +export async function mapDetail(env: Env, mapId: string, requestId: string): Promise { + if (!isCanonicalUuid(mapId)) throw new HttpError(404, "map_not_found", "The map was not found."); + const row = await publishedMapRow(env, mapId); + if (row === null) throw new HttpError(404, "map_not_found", "The map was not found."); + return jsonResponse({ requestId, map: mapDto(row) }, 200, requestId); +} + +export async function mapVersions(env: Env, mapId: string, requestId: string): Promise { + if (!isCanonicalUuid(mapId)) throw new HttpError(404, "map_not_found", "The map was not found."); + const visible = await env.DB.prepare(`SELECT 1 FROM maps WHERE id = ? AND status = 'published'`).bind(mapId).first(); + if (visible === null) throw new HttpError(404, "map_not_found", "The map was not found."); + const result = await env.DB.prepare( + `${MAP_SELECT} + WHERE m.id = ? AND m.status = 'published' AND v.status = 'published' + AND v.client_version = 29 AND v.map_format_version = 29 + ORDER BY v.revision DESC LIMIT 50`, + ).bind(mapId).all(); + return jsonResponse({ requestId, items: result.results.map(mapDto), nextCursor: null }, 200, requestId); +} + +export async function downloadableVersion(env: Env, mapId: string, revision: number): Promise { + if (!isCanonicalUuid(mapId) || !Number.isSafeInteger(revision) || revision < 1) { + throw new HttpError(404, "revision_not_found", "The map revision was not found."); + } + const row = await publishedMapRow(env, mapId, revision); + if (row === null) throw new HttpError(404, "revision_not_found", "The map revision was not found."); + mapDto(row); + return row; +} + +export type { MapRow }; diff --git a/backend/community-api/src/rate-limit.ts b/backend/community-api/src/rate-limit.ts new file mode 100644 index 0000000..d687236 --- /dev/null +++ b/backend/community-api/src/rate-limit.ts @@ -0,0 +1,43 @@ +import type { RuntimeConfig } from "./config"; +import { opaqueHash } from "./crypto"; +import { HttpError } from "./http"; + +function clientAddress(request: Request): string { + const value = request.headers.get("CF-Connecting-IP") ?? "unknown"; + if (value.length > 64 || /[\u0000-\u0020\u007f-\u009f]/u.test(value)) return "invalid"; + return value; +} + +export async function anonymousRateKey( + request: Request, + config: RuntimeConfig, + routeClass: string, +): Promise { + return opaqueHash(config, "rate-limit", `${routeClass}\u0000${clientAddress(request)}`); +} + +export async function actorRateKey( + config: RuntimeConfig, + routeClass: string, + steamId64: string, +): Promise { + return opaqueHash(config, "rate-limit", `${routeClass}\u0000steam\u0000${steamId64}`); +} + +export async function enforceRateLimit( + limiter: RateLimit | undefined, + key: string, + retryAfterSeconds = 60, +): Promise { + if (limiter === undefined || typeof limiter.limit !== "function") { + throw new HttpError(503, "temporarily_unavailable", "The request limiter is unavailable.", { + "Retry-After": "30", + }); + } + const result = await limiter.limit({ key }); + if (!result.success) { + throw new HttpError(429, "rate_limited", "Try again later.", { + "Retry-After": String(retryAfterSeconds), + }); + } +} diff --git a/backend/community-api/src/steam-openid.ts b/backend/community-api/src/steam-openid.ts new file mode 100644 index 0000000..f7faf18 --- /dev/null +++ b/backend/community-api/src/steam-openid.ts @@ -0,0 +1,326 @@ +import { isSteamId64 } from "@ss2revive/community-contracts"; +import type { RuntimeConfig } from "./config"; +import { opaqueHash, randomToken } from "./crypto"; +import { HttpError, readBoundedBody, responseHeaders } from "./http"; + +const STEAM_OPENID_ENDPOINT = "https://steamcommunity.com/openid/login"; +const OPENID_NAMESPACE = "http://specs.openid.net/auth/2.0"; +const IDENTIFIER_SELECT = "http://specs.openid.net/auth/2.0/identifier_select"; +const LOGIN_LIFETIME_MS = 10 * 60 * 1000; + +interface OpenIdSessionRow { + id: string; + device_session_id: string; + status: "pending" | "verified" | "confirmed" | "failed"; + return_to: string; + steam_id64: string | null; + expires_at: number; +} + +function escapeHtml(value: string): string { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">") + .replaceAll('"', """).replaceAll("'", "'"); +} + +function htmlResponse(html: string, status: number, requestId: string): Response { + return new Response(html, { + status, + headers: responseHeaders(requestId, { + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": "default-src 'none'; form-action 'self' https://steamcommunity.com; frame-ancestors 'none'; base-uri 'none'", + }), + }); +} + +function exactParameter(parameters: URLSearchParams, name: string, maximum = 2048): string { + const values = parameters.getAll(name); + if (values.length !== 1 || values[0] === undefined || values[0].length < 1 || values[0].length > maximum) { + throw new HttpError(400, "steam_assertion_invalid", "Steam authentication could not be verified."); + } + return values[0]; +} + +async function boundedText(response: Response, maximumBytes: number): Promise { + if (response.body === null) return ""; + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let total = 0; + let text = ""; + try { + for (;;) { + const result = await reader.read(); + if (result.done) break; + total += result.value.byteLength; + if (total > maximumBytes) { + await reader.cancel("response limit exceeded"); + throw new HttpError(502, "steam_unavailable", "Steam authentication is temporarily unavailable."); + } + text += decoder.decode(result.value, { stream: true }); + } + text += decoder.decode(); + return text; + } catch (error) { + if (error instanceof HttpError) throw error; + throw new HttpError(502, "steam_unavailable", "Steam authentication is temporarily unavailable."); + } finally { + reader.releaseLock(); + } +} + +export async function verifySteamOpenIdAssertion( + parameters: URLSearchParams, + expectedReturnTo: string, + nowMs: number, + fetcher: typeof fetch = fetch, +): Promise<{ steamId64: string; responseNonce: string }> { + if ([...parameters.keys()].length > 24) { + throw new HttpError(400, "steam_assertion_invalid", "Steam authentication could not be verified."); + } + const mode = exactParameter(parameters, "openid.mode", 64); + const namespace = exactParameter(parameters, "openid.ns", 128); + const endpoint = exactParameter(parameters, "openid.op_endpoint", 256); + const claimedId = exactParameter(parameters, "openid.claimed_id", 256); + const identity = exactParameter(parameters, "openid.identity", 256); + const returnTo = exactParameter(parameters, "openid.return_to", 512); + const responseNonce = exactParameter(parameters, "openid.response_nonce", 255); + const signedText = exactParameter(parameters, "openid.signed", 512); + exactParameter(parameters, "openid.assoc_handle", 255); + exactParameter(parameters, "openid.sig", 1024); + if ( + mode !== "id_res" || namespace !== OPENID_NAMESPACE || endpoint !== STEAM_OPENID_ENDPOINT || + claimedId !== identity || returnTo !== expectedReturnTo + ) { + throw new HttpError(400, "steam_assertion_invalid", "Steam authentication could not be verified."); + } + const match = /^https:\/\/steamcommunity\.com\/openid\/id\/([1-9][0-9]{16,19})$/u.exec(claimedId); + const steamId64 = match?.[1] ?? ""; + if (!isSteamId64(steamId64)) { + throw new HttpError(400, "steam_assertion_invalid", "Steam authentication could not be verified."); + } + const signed = new Set(signedText.split(",")); + for (const required of ["op_endpoint", "claimed_id", "identity", "return_to", "response_nonce", "assoc_handle"]) { + if (!signed.has(required)) { + throw new HttpError(400, "steam_assertion_invalid", "Steam authentication could not be verified."); + } + } + const nonceTimeText = responseNonce.slice(0, 20); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/u.test(nonceTimeText)) { + throw new HttpError(400, "steam_assertion_invalid", "Steam authentication could not be verified."); + } + const nonceTime = Date.parse(nonceTimeText); + if (!Number.isFinite(nonceTime) || nonceTime < nowMs - LOGIN_LIFETIME_MS || nonceTime > nowMs + 2 * 60 * 1000) { + throw new HttpError(400, "steam_assertion_invalid", "Steam authentication could not be verified."); + } + + const verification = new URLSearchParams(); + for (const [key, value] of parameters) { + if (key.startsWith("openid.")) verification.append(key, value); + } + verification.set("openid.mode", "check_authentication"); + let response: Response; + try { + response = await fetcher(STEAM_OPENID_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded; charset=utf-8" }, + body: verification.toString(), + redirect: "manual", + signal: AbortSignal.timeout(5000), + }); + } catch { + throw new HttpError(502, "steam_unavailable", "Steam authentication is temporarily unavailable.", { + "Retry-After": "30", + }); + } + if (response.status !== 200) { + throw new HttpError(502, "steam_unavailable", "Steam authentication is temporarily unavailable.", { + "Retry-After": "30", + }); + } + const responseText = await boundedText(response, 4096); + const fields = new Map(); + for (const line of responseText.split(/\r?\n/u)) { + if (line === "") continue; + const separator = line.indexOf(":"); + if (separator < 1) throw new HttpError(502, "steam_unavailable", "Steam returned an invalid response."); + const key = line.slice(0, separator); + if (fields.has(key)) throw new HttpError(502, "steam_unavailable", "Steam returned an invalid response."); + fields.set(key, line.slice(separator + 1)); + } + if (fields.get("is_valid") !== "true" || (fields.has("ns") && fields.get("ns") !== OPENID_NAMESPACE)) { + throw new HttpError(400, "steam_assertion_invalid", "Steam authentication could not be verified."); + } + return { steamId64, responseNonce }; +} + +export function steamActivationPage(request: Request, requestId: string): Response { + if (request.method !== "GET") { + throw new HttpError(405, "method_not_allowed", "The method is not allowed.", { Allow: "GET" }); + } + const code = new URL(request.url).searchParams.get("user_code")?.toUpperCase() ?? ""; + const value = /^[A-Z2-9]{4}-[A-Z2-9]{4}$/u.test(code) ? code : ""; + return htmlResponse(`Link SS2Revive to Steam +

Link SS2Revive to Steam

Enter the code shown by SS2Revive, then sign in on Steam.

+

SS2Revive never receives your Steam password.

+
+
`, 200, requestId); +} + +export async function startSteamLogin( + request: Request, + env: Env, + config: RuntimeConfig, + requestId: string, + nowMs: number, +): Promise { + const contentType = request.headers.get("Content-Type")?.split(";", 1)[0]?.trim().toLowerCase(); + if (contentType !== "application/x-www-form-urlencoded") { + throw new HttpError(415, "invalid_request", "The Steam login form has an invalid content type."); + } + const form = new URLSearchParams(new TextDecoder().decode(await readBoundedBody(request, 4096))); + const values = form.getAll("user_code"); + const code = values.length === 1 ? values[0]!.toUpperCase() : ""; + if (!/^[A-Z2-9]{4}-[A-Z2-9]{4}$/u.test(code)) { + throw new HttpError(400, "invalid_request", "The device code is invalid or expired."); + } + const codeHash = await opaqueHash(config, "user-code", code); + const device = await env.DB.prepare( + `SELECT id FROM device_auth_sessions + WHERE user_code_hash = ? AND status = 'pending' AND expires_at > ?`, + ).bind(codeHash, nowMs).first<{ id: string }>(); + if (device === null) throw new HttpError(400, "invalid_request", "The device code is invalid or expired."); + + const state = randomToken(); + const stateHash = await opaqueHash(config, "steam-openid-state", state); + const returnTo = `${config.publicOrigin}/v1/auth/steam/callback?state=${encodeURIComponent(state)}`; + const id = crypto.randomUUID(); + const result = await env.DB.prepare( + `INSERT INTO steam_openid_sessions + (id, device_session_id, state_hash, status, return_to, created_at, expires_at) + VALUES (?, ?, ?, 'pending', ?, ?, ?) + ON CONFLICT(device_session_id) DO UPDATE SET + id = excluded.id, state_hash = excluded.state_hash, status = 'pending', + return_to = excluded.return_to, steam_id64 = NULL, response_nonce_hash = NULL, + confirm_token_hash = NULL, created_at = excluded.created_at, + expires_at = excluded.expires_at, verified_at = NULL, confirmed_at = NULL + WHERE steam_openid_sessions.status IN ('pending', 'failed') + OR steam_openid_sessions.expires_at <= ?`, + ).bind(id, device.id, stateHash, returnTo, nowMs, nowMs + LOGIN_LIFETIME_MS, nowMs).run(); + if ((result.meta.changes ?? 0) !== 1) { + throw new HttpError(409, "steam_login_in_progress", "This device already has a verified Steam login to confirm."); + } + + const steamUrl = new URL(STEAM_OPENID_ENDPOINT); + steamUrl.searchParams.set("openid.ns", OPENID_NAMESPACE); + steamUrl.searchParams.set("openid.mode", "checkid_setup"); + steamUrl.searchParams.set("openid.return_to", returnTo); + steamUrl.searchParams.set("openid.realm", `${config.publicOrigin}/`); + steamUrl.searchParams.set("openid.identity", IDENTIFIER_SELECT); + steamUrl.searchParams.set("openid.claimed_id", IDENTIFIER_SELECT); + return new Response(null, { + status: 303, + headers: responseHeaders(requestId, { Location: steamUrl.toString() }), + }); +} + +export async function steamCallback( + request: Request, + env: Env, + config: RuntimeConfig, + requestId: string, + nowMs: number, + fetcher: typeof fetch = fetch, +): Promise { + const parameters = new URL(request.url).searchParams; + const state = exactParameter(parameters, "state", 64); + if (state.length !== 43) throw new HttpError(400, "steam_assertion_invalid", "Steam authentication could not be verified."); + const stateHash = await opaqueHash(config, "steam-openid-state", state); + const login = await env.DB.prepare( + `SELECT id, device_session_id, status, return_to, steam_id64, expires_at + FROM steam_openid_sessions WHERE state_hash = ?`, + ).bind(stateHash).first(); + if (login === null || login.status !== "pending" || login.expires_at <= nowMs) { + throw new HttpError(400, "steam_assertion_invalid", "The Steam login is invalid, expired, or already used."); + } + const verified = await verifySteamOpenIdAssertion(parameters, login.return_to, nowMs, fetcher); + const nonceHash = await opaqueHash(config, "steam-openid-nonce", verified.responseNonce); + const confirmToken = randomToken(); + const confirmHash = await opaqueHash(config, "steam-confirm-token", confirmToken); + let updated: { id: string } | null; + try { + updated = await env.DB.prepare( + `UPDATE steam_openid_sessions + SET status = 'verified', steam_id64 = ?, response_nonce_hash = ?, + confirm_token_hash = ?, verified_at = ? + WHERE id = ? AND status = 'pending' AND expires_at > ? + RETURNING id`, + ).bind(verified.steamId64, nonceHash, confirmHash, nowMs, login.id, nowMs).first<{ id: string }>(); + } catch { + throw new HttpError(400, "steam_assertion_invalid", "The Steam assertion was already used."); + } + if (updated === null) throw new HttpError(409, "steam_login_consumed", "The Steam login was already consumed."); + return htmlResponse(`Confirm SS2Revive link +

Confirm device link

Steam account ${escapeHtml(verified.steamId64)} was verified.

+

Approve linking this Steam account to the SS2Revive device code you entered?

+
+ + +
`, 200, requestId); +} + +export async function confirmSteamLogin( + request: Request, + env: Env, + config: RuntimeConfig, + requestId: string, + nowMs: number, +): Promise { + const contentType = request.headers.get("Content-Type")?.split(";", 1)[0]?.trim().toLowerCase(); + if (contentType !== "application/x-www-form-urlencoded") { + throw new HttpError(415, "invalid_request", "The confirmation form has an invalid content type."); + } + const form = new URLSearchParams(new TextDecoder().decode(await readBoundedBody(request, 4096))); + const states = form.getAll("state"); + const tokens = form.getAll("confirm_token"); + const state = states.length === 1 ? states[0]! : ""; + const token = tokens.length === 1 ? tokens[0]! : ""; + if (state.length !== 43 || token.length !== 43) { + throw new HttpError(400, "invalid_request", "The confirmation is invalid or expired."); + } + const login = await env.DB.prepare( + `SELECT id, device_session_id, status, return_to, steam_id64, expires_at + FROM steam_openid_sessions + WHERE state_hash = ? AND confirm_token_hash = ?`, + ).bind( + await opaqueHash(config, "steam-openid-state", state), + await opaqueHash(config, "steam-confirm-token", token), + ).first(); + if (login === null || login.status !== "verified" || login.expires_at <= nowMs || !isSteamId64(login.steam_id64)) { + throw new HttpError(400, "invalid_request", "The confirmation is invalid or expired."); + } + const results = await env.DB.batch([ + env.DB.prepare( + `INSERT INTO users (steam_id64, status, created_at, last_login_at) + VALUES (?, 'active', ?, ?) + ON CONFLICT(steam_id64) DO UPDATE SET last_login_at = excluded.last_login_at`, + ).bind(login.steam_id64, nowMs, nowMs), + env.DB.prepare( + `UPDATE device_auth_sessions SET status = 'approved', steam_id64 = ?, approved_at = ? + WHERE id = ? AND status = 'pending' AND expires_at > ?`, + ).bind(login.steam_id64, nowMs, login.device_session_id, nowMs), + env.DB.prepare( + `UPDATE steam_openid_sessions SET status = 'confirmed', confirmed_at = ?, confirm_token_hash = NULL + WHERE id = ? AND status = 'verified' + AND EXISTS (SELECT 1 FROM device_auth_sessions d + WHERE d.id = steam_openid_sessions.device_session_id + AND d.status = 'approved' AND d.steam_id64 = ?) + RETURNING id`, + ).bind(nowMs, login.id, login.steam_id64), + ]); + const confirmed = results[2]?.results as Array<{ id?: unknown }> | undefined; + if (confirmed?.[0]?.id !== login.id) { + throw new HttpError(409, "steam_login_consumed", "The Steam login could not be confirmed."); + } + return htmlResponse(`SS2Revive linked +

Device approved

Return to SS2Revive to finish signing in.

`, 200, requestId); +} diff --git a/backend/community-api/src/worker.ts b/backend/community-api/src/worker.ts new file mode 100644 index 0000000..f173e4b --- /dev/null +++ b/backend/community-api/src/worker.ts @@ -0,0 +1,247 @@ +import { isCanonicalUuid } from "@ss2revive/community-contracts"; +import { + authenticate, + createDeviceSession, + localActivationPage, + logoutSession, + pollDeviceSession, + refreshSession, +} from "./auth"; +import { runtimeConfig } from "./config"; +import { opaqueHash } from "./crypto"; +import { downloadObject } from "./downloads"; +import { errorResponse, HttpError, jsonResponse, requireMethod } from "./http"; +import { seedLocalFixture } from "./local-fixture"; +import { cleanupExpiredState } from "./maintenance"; +import { listMaps, mapDetail, mapVersions } from "./maps"; +import { actorRateKey, anonymousRateKey, enforceRateLimit } from "./rate-limit"; +import { + confirmSteamLogin, + startSteamLogin, + steamActivationPage, + steamCallback, +} from "./steam-openid"; + +function safePath(request: Request): string { + if (request.url.length > 4096) throw new HttpError(414, "invalid_request", "The request URL is too long."); + const rawAfterScheme = request.url.indexOf("://"); + const rawPathStart = request.url.indexOf("/", rawAfterScheme < 0 ? 0 : rawAfterScheme + 3); + const rawPathWithQuery = rawPathStart < 0 ? "/" : request.url.slice(rawPathStart); + const rawPath = rawPathWithQuery.split("?", 1)[0] ?? "/"; + if ( + rawPath.length > 1024 || rawPath.includes("\\") || rawPath.includes("//") || + /%(?:2f|5c|2e|25)/iu.test(rawPath) || /[\u0000-\u001f\u007f-\u009f]/u.test(rawPath) + ) { + throw new HttpError(400, "invalid_request", "The request path is invalid."); + } + let decoded: string; + try { + decoded = decodeURIComponent(rawPath); + } catch { + throw new HttpError(400, "invalid_request", "The request path is invalid."); + } + if (decoded !== new URL(request.url).pathname) { + throw new HttpError(400, "invalid_request", "The request path is ambiguous."); + } + return decoded; +} + +function requireLoopbackMockRoute(request: Request, allowMockAuth: boolean): void { + const url = new URL(request.url); + const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]"; + if (!allowMockAuth || !loopback || url.protocol !== "http:") { + throw new HttpError(404, "not_found", "The route was not found."); + } +} + +function requireCanonicalOrigin(request: Request, config: ReturnType): void { + if (new URL(request.url).origin !== config.publicOrigin) { + throw new HttpError(421, "misdirected_request", "The request was sent to an unexpected origin."); + } +} + +async function limitAnonymous( + request: Request, + env: Env, + config: ReturnType, + routeClass: string, +): Promise { + await enforceRateLimit(env.AUTH_RATE_LIMITER, await anonymousRateKey(request, config, routeClass)); +} + +async function limitActor( + env: Env, + config: ReturnType, + routeClass: string, + steamId64: string, + download = false, +): Promise { + const limiter = download ? env.DOWNLOAD_RATE_LIMITER : env.API_RATE_LIMITER; + await enforceRateLimit(limiter, await actorRateKey(config, routeClass, steamId64)); +} + +async function localSeed( + request: Request, + env: Env, + requestId: string, +): Promise { + const config = runtimeConfig(env); + requireLoopbackMockRoute(request, config.allowMockAuth); + requireMethod(request, "POST"); + const supplied = request.headers.get("X-Local-Setup-Secret") ?? ""; + const suppliedHash = await opaqueHash(config, "local-setup", supplied); + const expectedHash = await opaqueHash(config, "local-setup", config.authSecret); + if (supplied.length < 32 || suppliedHash !== expectedHash) { + throw new HttpError(401, "auth_required", "The local setup secret is required."); + } + if (request.body !== null || Number(request.headers.get("Content-Length") ?? "0") !== 0) { + throw new HttpError(400, "invalid_request", "The local seed request must not have a body."); + } + const fixture = await seedLocalFixture(env, config); + return jsonResponse({ requestId, seeded: fixture }, 200, requestId); +} + +async function route(request: Request, env: Env, requestId: string): Promise { + const config = runtimeConfig(env); + requireCanonicalOrigin(request, config); + const path = safePath(request); + const nowMs = Date.now(); + + if (path === "/health") { + requireMethod(request, "GET"); + return jsonResponse({ status: "ok" }, 200, requestId); + } + if (path === "/_local/seed") return localSeed(request, env, requestId); + if (path === "/activate") { + await limitAnonymous(request, env, config, "activation"); + if (!config.allowMockAuth) return steamActivationPage(request, requestId); + requireLoopbackMockRoute(request, config.allowMockAuth); + return localActivationPage(request, env, config, requestId, nowMs); + } + + if (path === "/v1/auth/device-sessions") { + requireMethod(request, "POST"); + await limitAnonymous(request, env, config, "device-create"); + return createDeviceSession(request, env, config, requestId, nowMs); + } + if (path === "/v1/auth/device-sessions/token") { + requireMethod(request, "POST"); + await limitAnonymous(request, env, config, "device-poll"); + return pollDeviceSession(request, env, config, requestId, nowMs); + } + if (path === "/v1/auth/refresh") { + requireMethod(request, "POST"); + await limitAnonymous(request, env, config, "refresh"); + return refreshSession(request, env, config, requestId, nowMs); + } + if (path === "/v1/auth/logout") { + requireMethod(request, "POST"); + await limitAnonymous(request, env, config, "logout"); + return logoutSession(request, env, config, requestId, nowMs); + } + if (path === "/v1/auth/steam/start") { + requireMethod(request, "POST"); + if (config.allowMockAuth) throw new HttpError(404, "not_found", "The route was not found."); + await limitAnonymous(request, env, config, "steam-start"); + return startSteamLogin(request, env, config, requestId, nowMs); + } + if (path === "/v1/auth/steam/callback") { + requireMethod(request, "GET"); + if (config.allowMockAuth) throw new HttpError(404, "not_found", "The route was not found."); + await limitAnonymous(request, env, config, "steam-callback"); + return steamCallback(request, env, config, requestId, nowMs); + } + if (path === "/v1/auth/steam/confirm") { + requireMethod(request, "POST"); + if (config.allowMockAuth) throw new HttpError(404, "not_found", "The route was not found."); + await limitAnonymous(request, env, config, "steam-confirm"); + return confirmSteamLogin(request, env, config, requestId, nowMs); + } + if (path === "/v1/me") { + requireMethod(request, "GET"); + const principal = await authenticate(request, env, config, "maps:read", nowMs); + await limitActor(env, config, "account", principal.steamId64); + return jsonResponse({ requestId, steamId64: principal.steamId64, scopes: principal.scopes }, 200, requestId); + } + + if (path === "/v1/maps") { + requireMethod(request, "GET"); + const principal = await authenticate(request, env, config, "maps:read", nowMs); + await limitActor(env, config, "maps", principal.steamId64); + return listMaps(request, env, config, requestId, nowMs); + } + + const downloadMatch = /^\/v1\/maps\/([^/]+)\/versions\/([1-9][0-9]*)\/(download|thumbnail)$/u.exec(path); + if (downloadMatch !== null) { + const mapId = downloadMatch[1] ?? ""; + const revision = Number(downloadMatch[2]); + if (!isCanonicalUuid(mapId) || !Number.isSafeInteger(revision)) { + throw new HttpError(404, "revision_not_found", "The map revision was not found."); + } + const principal = await authenticate(request, env, config, "maps:download", nowMs); + await limitActor(env, config, "download", principal.steamId64, true); + return downloadObject( + request, + env, + config, + principal.steamId64, + mapId, + revision, + downloadMatch[3] === "download" ? "bundle" : "thumbnail", + requestId, + nowMs, + ); + } + + const versionsMatch = /^\/v1\/maps\/([^/]+)\/versions$/u.exec(path); + if (versionsMatch !== null) { + requireMethod(request, "GET"); + const principal = await authenticate(request, env, config, "maps:read", nowMs); + await limitActor(env, config, "maps", principal.steamId64); + return mapVersions(env, versionsMatch[1] ?? "", requestId); + } + + const detailMatch = /^\/v1\/maps\/([^/]+)$/u.exec(path); + if (detailMatch !== null) { + requireMethod(request, "GET"); + const principal = await authenticate(request, env, config, "maps:read", nowMs); + await limitActor(env, config, "maps", principal.steamId64); + return mapDetail(env, detailMatch[1] ?? "", requestId); + } + + throw new HttpError(404, "not_found", "The route was not found."); +} + +export default { + async fetch(request: Request, env: Env): Promise { + const requestId = crypto.randomUUID(); + const startedAt = Date.now(); + let response: Response; + try { + response = await route(request, env, requestId); + } catch (error) { + if (error instanceof HttpError) response = errorResponse(error, requestId); + else { + console.error(JSON.stringify({ event: "unhandled_error", requestId, name: error instanceof Error ? error.name : "unknown" })); + response = errorResponse(new HttpError(500, "internal_error", "The request could not be completed."), requestId); + } + } + if (env.ENVIRONMENT === "production") { + response.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); + } + if (env.ENVIRONMENT !== "local" || response.status >= 400) { + console.log(JSON.stringify({ + event: "http_request", + requestId, + method: request.method, + pathname: new URL(request.url).pathname.slice(0, 256), + status: response.status, + durationMs: Date.now() - startedAt, + })); + } + return response; + }, + async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise { + ctx.waitUntil(cleanupExpiredState(env, controller.scheduledTime)); + }, +} satisfies ExportedHandler; diff --git a/backend/community-api/test/apply-migrations.ts b/backend/community-api/test/apply-migrations.ts new file mode 100644 index 0000000..34141ef --- /dev/null +++ b/backend/community-api/test/apply-migrations.ts @@ -0,0 +1,4 @@ +import { env } from "cloudflare:workers"; +import { applyD1Migrations } from "cloudflare:test"; + +await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); diff --git a/backend/community-api/test/bundle.test.ts b/backend/community-api/test/bundle.test.ts new file mode 100644 index 0000000..c082b81 --- /dev/null +++ b/backend/community-api/test/bundle.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + BundleValidationError, + inspectLevelBundle, + levelCodeFromId, + MAX_BUNDLE_BYTES, +} from "@ss2revive/community-contracts"; +import { buildLocalFixture } from "../src/local-fixture"; + +function expectBundleError(code: string, action: () => Promise): Promise { + return expect(action()).rejects.toMatchObject({ name: "BundleValidationError", code }); +} + +describe("Level bundle contract", () => { + it("matches the game's fixed GUID share-code vector", () => { + expect(levelCodeFromId("1a658233-92c5-4b63-87fc-4740c855730b")).toBe("M4JlGsWSY0uH_EdAyFVzCw"); + expect(levelCodeFromId("not-a-guid")).toBe(""); + }); + + it("accepts the deterministic current-format fixture", async () => { + const fixture = await buildLocalFixture(); + const parsed = await inspectLevelBundle(fixture.bytes, { nowMs: Date.UTC(2026, 7, 8) }); + expect(parsed.manifest.id).toBe("1a658233-92c5-4b63-87fc-4740c855730b"); + expect(parsed.manifest.clientVersion).toBe(29); + expect(parsed.code).toBe("M4JlGsWSY0uH_EdAyFVzCw"); + expect(parsed.bundleSha256).toBe(fixture.sha256); + expect(parsed.thumbnailInfo).toMatchObject({ width: 1, height: 1, format: 4, dataLength: 4 }); + }); + + it("rejects truncation, trailing bytes, and checksum tampering", async () => { + const fixture = await buildLocalFixture(); + await expectBundleError("bundle_malformed", () => inspectLevelBundle(fixture.bytes.slice(0, -1))); + const trailing = new Uint8Array(fixture.bytes.length + 1); + trailing.set(fixture.bytes); + await expectBundleError("bundle_trailing_data", () => inspectLevelBundle(trailing)); + const tampered = fixture.bytes.slice(); + const contentMarker = new TextEncoder().encode("Surgeons"); + const contentIndex = tampered.findIndex((_, offset) => + contentMarker.every((value, inner) => tampered[offset + inner] === value)); + expect(contentIndex).toBeGreaterThan(0); + const tamperedIndex = contentIndex + 10; + tampered[tamperedIndex] = (tampered[tamperedIndex] ?? 0) ^ 1; + await expectBundleError("content_checksum_invalid", () => inspectLevelBundle(tampered)); + }); + + it("rejects an oversized bundle before parsing", async () => { + await expectBundleError("bundle_oversized", () => inspectLevelBundle(new Uint8Array(MAX_BUNDLE_BYTES + 1))); + }); + + it("rejects legacy container versions", async () => { + const fixture = await buildLocalFixture(); + const legacy = fixture.bytes.slice(); + legacy[16] = 1; + legacy[17] = 0; + await expectBundleError("bundle_version_invalid", () => inspectLevelBundle(legacy)); + }); + + it("rejects duplicate manifest keys", async () => { + const fixture = await buildLocalFixture(); + const bytes = fixture.bytes.slice(); + const marker = new TextEncoder().encode('"title":'); + const index = bytes.findIndex((_, offset) => marker.every((value, inner) => bytes[offset + inner] === value)); + expect(index).toBeGreaterThan(0); + const replacement = new TextEncoder().encode('"id" :'); + expect(replacement.length).toBe(marker.length); + bytes.set(replacement, index); + await expectBundleError("manifest_duplicate_key", () => inspectLevelBundle(bytes)); + }); + + it("rejects duplicate known container entries", async () => { + const fixture = await buildLocalFixture(); + const marker = new TextEncoder().encode("thumb.png"); + const nameIndex = fixture.bytes.findIndex((_, offset) => + marker.every((value, inner) => fixture.bytes[offset + inner] === value)); + expect(nameIndex).toBeGreaterThan(1); + const entryStart = nameIndex - 2; + const entryLength = 2 + marker.length + 4 + fixture.thumbnail.length; + const duplicate = fixture.bytes.slice(entryStart, entryStart + entryLength); + const bytes = new Uint8Array(fixture.bytes.length + duplicate.length); + bytes.set(fixture.bytes.slice(0, -2)); + bytes.set(duplicate, fixture.bytes.length - 2); + await expectBundleError("bundle_duplicate_entry", () => inspectLevelBundle(bytes)); + }); + + it("exposes typed validation errors without raw payloads", () => { + const error = new BundleValidationError("bundle_malformed", "safe message"); + expect(error).toMatchObject({ name: "BundleValidationError", code: "bundle_malformed", message: "safe message" }); + }); +}); diff --git a/backend/community-api/test/phase1.test.ts b/backend/community-api/test/phase1.test.ts new file mode 100644 index 0000000..6d4f2e9 --- /dev/null +++ b/backend/community-api/test/phase1.test.ts @@ -0,0 +1,219 @@ +import { env } from "cloudflare:workers"; +import { beforeEach, describe, expect, it } from "vitest"; +import { createDeviceSession, pollDeviceSession } from "../src/auth"; +import { runtimeConfig, type RuntimeConfig } from "../src/config"; +import { completeDownload, reserveDownload } from "../src/download-quota"; +import { HttpError } from "../src/http"; +import { cleanupExpiredState } from "../src/maintenance"; +import { enforceRateLimit } from "../src/rate-limit"; +import { + confirmSteamLogin, + startSteamLogin, + steamCallback, + verifySteamOpenIdAssertion, +} from "../src/steam-openid"; + +const LOCAL_ORIGIN = "http://127.0.0.1:8787"; +const PUBLIC_ORIGIN = "https://community.m12labs.net"; + +function productionConfig(): RuntimeConfig { + return runtimeConfig({ + ...env, + ENVIRONMENT: "production", + ALLOW_MOCK_AUTH: "false", + PUBLIC_ORIGIN, + }); +} + +async function json(response: Response): Promise { + return response.json() as Promise; +} + +async function clearDatabase(): Promise { + await env.DB.batch([ + env.DB.prepare("DELETE FROM download_leases"), + env.DB.prepare("DELETE FROM download_usage_daily"), + env.DB.prepare("DELETE FROM steam_openid_sessions"), + env.DB.prepare("DELETE FROM refresh_tokens"), + env.DB.prepare("DELETE FROM refresh_token_families"), + env.DB.prepare("DELETE FROM auth_sessions"), + env.DB.prepare("DELETE FROM device_auth_sessions"), + env.DB.prepare("DELETE FROM users"), + env.DB.prepare("DELETE FROM map_tags"), + env.DB.prepare("DELETE FROM map_versions"), + env.DB.prepare("DELETE FROM maps"), + ]); +} + +function steamAssertion(returnTo: string, state: string, nowMs: number): URLSearchParams { + const steamId64 = "76561198145479980"; + const parameters = new URLSearchParams({ + state, + "openid.ns": "http://specs.openid.net/auth/2.0", + "openid.mode": "id_res", + "openid.op_endpoint": "https://steamcommunity.com/openid/login", + "openid.claimed_id": `https://steamcommunity.com/openid/id/${steamId64}`, + "openid.identity": `https://steamcommunity.com/openid/id/${steamId64}`, + "openid.return_to": returnTo, + "openid.response_nonce": `${new Date(nowMs).toISOString().replace(/\.\d{3}Z$/u, "Z")}phase1-test`, + "openid.assoc_handle": "1234567890", + "openid.signed": "signed,op_endpoint,claimed_id,identity,return_to,response_nonce,assoc_handle", + "openid.sig": "test-signature", + }); + return parameters; +} + +const validSteamFetcher = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + expect(String(input)).toBe("https://steamcommunity.com/openid/login"); + expect(init?.method).toBe("POST"); + expect(String(init?.body)).toContain("openid.mode=check_authentication"); + return new Response("ns:http://specs.openid.net/auth/2.0\nis_valid:true\n", { status: 200 }); +}) as typeof fetch; + +beforeEach(clearDatabase); + +describe("Phase 1 production authentication", () => { + it("verifies a Steam OpenID assertion through direct provider verification", async () => { + const nowMs = Date.UTC(2026, 7, 8, 12, 0, 0); + const returnTo = `${PUBLIC_ORIGIN}/v1/auth/steam/callback?state=test`; + const result = await verifySteamOpenIdAssertion( + steamAssertion(returnTo, "test", nowMs), returnTo, nowMs, validSteamFetcher, + ); + expect(result.steamId64).toBe("76561198145479980"); + await expect(verifySteamOpenIdAssertion( + steamAssertion(`${PUBLIC_ORIGIN}/wrong`, "test", nowMs), returnTo, nowMs, validSteamFetcher, + )).rejects.toMatchObject({ code: "steam_assertion_invalid" }); + }); + + it("links a pending device only after Steam verification and explicit confirmation", async () => { + const nowMs = Date.UTC(2026, 7, 8, 12, 0, 0); + const config = productionConfig(); + const created = await createDeviceSession( + new Request(`${PUBLIC_ORIGIN}/v1/auth/device-sessions`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: "{}", + }), env, config, crypto.randomUUID(), nowMs, + ); + const device = await json<{ deviceSessionId: string; deviceSecret: string; userCode: string }>(created); + const start = await startSteamLogin( + new Request(`${PUBLIC_ORIGIN}/v1/auth/steam/start`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ user_code: device.userCode }), + }), env, config, crypto.randomUUID(), nowMs, + ); + expect(start.status).toBe(303); + const providerUrl = new URL(start.headers.get("Location")!); + const returnTo = providerUrl.searchParams.get("openid.return_to")!; + const state = new URL(returnTo).searchParams.get("state")!; + const assertion = steamAssertion(returnTo, state, nowMs); + const callback = await steamCallback( + new Request(`${PUBLIC_ORIGIN}/v1/auth/steam/callback?${assertion}`), + env, config, crypto.randomUUID(), nowMs, validSteamFetcher, + ); + expect(callback.status).toBe(200); + const callbackHtml = await callback.text(); + const confirmToken = /name="confirm_token" value="([A-Za-z0-9_-]{43})"/u.exec(callbackHtml)?.[1]; + expect(confirmToken).toBeTruthy(); + const confirmation = await confirmSteamLogin( + new Request(`${PUBLIC_ORIGIN}/v1/auth/steam/confirm`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ state, confirm_token: confirmToken! }), + }), env, config, crypto.randomUUID(), nowMs, + ); + expect(confirmation.status).toBe(200); + const token = await pollDeviceSession( + new Request(`${PUBLIC_ORIGIN}/v1/auth/device-sessions/token`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deviceSessionId: device.deviceSessionId, deviceSecret: device.deviceSecret }), + }), env, config, crypto.randomUUID(), nowMs + 1, + ); + expect(token.status).toBe(200); + expect((await json<{ tokenType: string }>(token)).tokenType).toBe("Bearer"); + await expect(confirmSteamLogin( + new Request(`${PUBLIC_ORIGIN}/v1/auth/steam/confirm`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ state, confirm_token: confirmToken! }), + }), env, config, crypto.randomUUID(), nowMs + 2, + )).rejects.toMatchObject({ code: "invalid_request" }); + }); + + it("enforces the server-provided device polling interval", async () => { + const nowMs = Date.UTC(2026, 7, 8, 12, 0, 0); + const config = runtimeConfig(env); + const created = await createDeviceSession( + new Request(`${LOCAL_ORIGIN}/v1/auth/device-sessions`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: "{}", + }), env, config, crypto.randomUUID(), nowMs, + ); + const device = await json<{ deviceSessionId: string; deviceSecret: string }>(created); + const request = (): Request => new Request(`${LOCAL_ORIGIN}/v1/auth/device-sessions/token`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deviceSessionId: device.deviceSessionId, deviceSecret: device.deviceSecret }), + }); + expect((await pollDeviceSession(request(), env, config, crypto.randomUUID(), nowMs)).status).toBe(202); + await expect(pollDeviceSession(request(), env, config, crypto.randomUUID(), nowMs + 1000)) + .rejects.toMatchObject({ code: "authorization_slow_down", status: 429 }); + }); +}); + +describe("Phase 1 abuse controls", () => { + it("returns a stable 429 when the edge limiter denies a request", async () => { + const limiter = { limit: async () => ({ success: false }) } as unknown as RateLimit; + await expect(enforceRateLimit(limiter, "actor-key")) + .rejects.toMatchObject({ code: "rate_limited", status: 429 }); + }); + + it("enforces exact per-user concurrency and daily byte reservations", async () => { + const steamId64 = "76561198145479980"; + const nowMs = Date.UTC(2026, 7, 8, 12, 0, 0); + await env.DB.prepare( + `INSERT INTO users (steam_id64, status, created_at, last_login_at) VALUES (?, 'active', ?, ?)`, + ).bind(steamId64, nowMs, nowMs).run(); + const base = runtimeConfig(env); + const concurrencyConfig = { ...base, downloadDailyBytes: 10_000, downloadConcurrency: 2 }; + const first = await reserveDownload(env, concurrencyConfig, steamId64, 100, nowMs); + const second = await reserveDownload(env, concurrencyConfig, steamId64, 100, nowMs); + await expect(reserveDownload(env, concurrencyConfig, steamId64, 100, nowMs)) + .rejects.toMatchObject({ code: "download_concurrency_exceeded" }); + await completeDownload(env, first); + await completeDownload(env, second); + + const quotaConfig = { ...base, downloadDailyBytes: 1000, downloadConcurrency: 3 }; + const third = await reserveDownload(env, quotaConfig, steamId64, 700, nowMs + 1); + await completeDownload(env, third); + await expect(reserveDownload(env, quotaConfig, steamId64, 200, nowMs + 2)) + .rejects.toMatchObject({ code: "quota_exceeded" }); + }); + + it("uses typed HTTP errors for unavailable limiter bindings", async () => { + await expect(enforceRateLimit(undefined, "actor-key")) + .rejects.toBeInstanceOf(HttpError); + }); + + it("cleans expired download leases and old usage counters", async () => { + const steamId64 = "76561198145479980"; + const nowMs = Date.UTC(2026, 7, 8, 12, 0, 0); + await env.DB.prepare( + `INSERT INTO users (steam_id64, status, created_at, last_login_at) VALUES (?, 'active', ?, ?)`, + ).bind(steamId64, nowMs, nowMs).run(); + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO download_usage_daily + (steam_id64, day_utc, bytes_reserved, download_starts, updated_at) + VALUES (?, '2026-07-01', 100, 1, ?)`, + ).bind(steamId64, nowMs), + env.DB.prepare( + `INSERT INTO download_leases + (id, steam_id64, day_utc, bytes_reserved, created_at, expires_at) + VALUES (?, ?, '2026-08-08', 100, ?, ?)`, + ).bind(crypto.randomUUID(), steamId64, nowMs - 1000, nowMs - 1), + ]); + + await cleanupExpiredState(env, nowMs); + + expect(await env.DB.prepare("SELECT id FROM download_leases").first()).toBeNull(); + expect(await env.DB.prepare("SELECT day_utc FROM download_usage_daily").first()).toBeNull(); + }); +}); diff --git a/backend/community-api/test/worker.test.ts b/backend/community-api/test/worker.test.ts new file mode 100644 index 0000000..ae6a9c6 --- /dev/null +++ b/backend/community-api/test/worker.test.ts @@ -0,0 +1,260 @@ +import { env } from "cloudflare:workers"; +import { beforeEach, describe, expect, it } from "vitest"; +import { runtimeConfig } from "../src/config"; +import { approveLocalDeviceByCode } from "../src/auth"; +import worker from "../src/worker"; + +const ORIGIN = "http://127.0.0.1:8787"; + +async function fetchApi(path: string, init?: RequestInit): Promise { + return worker.fetch(new Request(`${ORIGIN}${path}`, init), env); +} + +async function json(response: Response): Promise { + return response.json() as Promise; +} + +async function accessToken(): Promise<{ accessToken: string; refreshToken: string }> { + const created = await fetchApi("/v1/auth/device-sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + expect(created.status).toBe(201); + const session = await json<{ deviceSessionId: string; deviceSecret: string; userCode: string }>(created); + expect(await approveLocalDeviceByCode(env, runtimeConfig(env), session.userCode, Date.now())).toBe(true); + const token = await fetchApi("/v1/auth/device-sessions/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deviceSessionId: session.deviceSessionId, deviceSecret: session.deviceSecret }), + }); + expect(token.status).toBe(200); + return json<{ accessToken: string; refreshToken: string }>(token); +} + +async function seed(): Promise { + const response = await fetchApi("/_local/seed", { + method: "POST", + headers: { "X-Local-Setup-Secret": env.LOCAL_AUTH_SECRET }, + }); + expect(response.status).toBe(200); +} + +beforeEach(async () => { + await env.DB.batch([ + env.DB.prepare("DELETE FROM download_leases"), + env.DB.prepare("DELETE FROM download_usage_daily"), + env.DB.prepare("DELETE FROM steam_openid_sessions"), + env.DB.prepare("DELETE FROM refresh_tokens"), + env.DB.prepare("DELETE FROM refresh_token_families"), + env.DB.prepare("DELETE FROM auth_sessions"), + env.DB.prepare("DELETE FROM device_auth_sessions"), + env.DB.prepare("DELETE FROM users"), + env.DB.prepare("DELETE FROM map_tags"), + env.DB.prepare("DELETE FROM map_versions"), + env.DB.prepare("DELETE FROM maps"), + ]); +}); + +describe("Phase 0 Worker", () => { + it("returns only shallow health and security headers", async () => { + const response = await fetchApi("/health"); + expect(response.status).toBe(200); + expect(await json(response)).toEqual({ status: "ok" }); + expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(response.headers.get("X-Request-Id")).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("requires authentication before map metadata", async () => { + const response = await fetchApi("/v1/maps"); + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toContain("Bearer"); + expect((await json<{ error: { code: string } }>(response)).error.code).toBe("auth_required"); + }); + + it("supports pending, approval, one-time consumption, and /me", async () => { + const created = await fetchApi("/v1/auth/device-sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + const session = await json<{ deviceSessionId: string; deviceSecret: string; userCode: string }>(created); + const pollBody = JSON.stringify({ deviceSessionId: session.deviceSessionId, deviceSecret: session.deviceSecret }); + const pending = await fetchApi("/v1/auth/device-sessions/token", { + method: "POST", headers: { "Content-Type": "application/json" }, body: pollBody, + }); + expect(pending.status).toBe(202); + expect(pending.headers.get("Retry-After")).toBe("5"); + expect(await approveLocalDeviceByCode(env, runtimeConfig(env), session.userCode, Date.now())).toBe(true); + const approved = await fetchApi("/v1/auth/device-sessions/token", { + method: "POST", headers: { "Content-Type": "application/json" }, body: pollBody, + }); + expect(approved.status).toBe(200); + const tokens = await json<{ accessToken: string; refreshToken: string }>(approved); + const consumed = await fetchApi("/v1/auth/device-sessions/token", { + method: "POST", headers: { "Content-Type": "application/json" }, body: pollBody, + }); + expect(consumed.status).toBe(409); + const me = await fetchApi("/v1/me", { headers: { Authorization: `Bearer ${tokens.accessToken}` } }); + expect(me.status).toBe(200); + expect((await json<{ steamId64: string }>(me)).steamId64).toBe(env.MOCK_STEAM_ID64); + }); + + it("rotates refresh tokens and revokes the family on replay", async () => { + const initial = await accessToken(); + const refresh = await fetchApi("/v1/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken: initial.refreshToken }), + }); + expect(refresh.status).toBe(200); + const rotated = await json<{ accessToken: string; refreshToken: string }>(refresh); + expect(rotated.refreshToken).not.toBe(initial.refreshToken); + const replay = await fetchApi("/v1/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken: initial.refreshToken }), + }); + expect(replay.status).toBe(401); + const familyRevoked = await fetchApi("/v1/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken: rotated.refreshToken }), + }); + expect(familyRevoked.status).toBe(401); + const oldAccessRevoked = await fetchApi("/v1/me", { headers: { Authorization: `Bearer ${rotated.accessToken}` } }); + expect(oldAccessRevoked.status).toBe(401); + }); + + it("lists, filters, details, and downloads the seeded bundle", async () => { + await seed(); + const tokens = await accessToken(); + const headers = { Authorization: `Bearer ${tokens.accessToken}` }; + const list = await fetchApi("/v1/maps?players=1&tag=TEAM_COOP&query=Phase&limit=1", { headers }); + expect(list.status).toBe(200); + const page = await json<{ items: Array<{ id: string; thumbnail: { url: string }; downloadUrl: string }> }>(list); + expect(page.items).toHaveLength(1); + expect(page.items[0]?.id).toBe("1a658233-92c5-4b63-87fc-4740c855730b"); + expect(page.items[0]?.thumbnail.url).toContain("/versions/1/thumbnail"); + + const detail = await fetchApi(`/v1/maps/${page.items[0]!.id}`, { headers }); + expect(detail.status).toBe(200); + const downloadPath = page.items[0]!.downloadUrl; + const full = await fetchApi(downloadPath, { headers }); + expect(full.status).toBe(200); + const bytes = new Uint8Array(await full.arrayBuffer()); + expect(bytes.length).toBe(Number(full.headers.get("Content-Length"))); + expect(new TextDecoder().decode(bytes.slice(0, 16))).toBe("SS2REVIVE LEVEL\n"); + const etag = full.headers.get("ETag")!; + + const head = await fetchApi(downloadPath, { method: "HEAD", headers }); + expect(head.status).toBe(200); + expect((await head.arrayBuffer()).byteLength).toBe(0); + expect(head.headers.get("ETag")).toBe(etag); + + const partial = await fetchApi(downloadPath, { headers: { ...headers, Range: "bytes=0-15", "If-Range": etag } }); + expect(partial.status).toBe(206); + expect(partial.headers.get("Content-Range")).toBe(`bytes 0-15/${bytes.length}`); + expect(new Uint8Array(await partial.arrayBuffer())).toEqual(bytes.slice(0, 16)); + + const notModified = await fetchApi(downloadPath, { headers: { ...headers, "If-None-Match": etag } }); + expect(notModified.status).toBe(304); + const rangedNotModified = await fetchApi(downloadPath, { + headers: { ...headers, Range: "bytes=0-15", "If-None-Match": etag }, + }); + expect(rangedNotModified.status).toBe(304); + const invalidRange = await fetchApi(downloadPath, { headers: { ...headers, Range: "bytes=0-1,4-5" } }); + expect(invalidRange.status).toBe(416); + }); + + it("rejects cursor tampering and ambiguous paths", async () => { + await seed(); + const secondId = "2a658233-92c5-4b63-87fc-4740c855730b"; + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO maps (id, status, current_revision, title, title_sort, description, created_at_ms, updated_at_ms) + SELECT ?, status, current_revision, 'Second room', 'second room', description, created_at_ms, updated_at_ms - 1 + FROM maps WHERE id = ?`, + ).bind(secondId, "1a658233-92c5-4b63-87fc-4740c855730b"), + env.DB.prepare( + `INSERT INTO map_versions + (map_id, revision, status, code, creator_ids_json, tags_json, configurations_json, + validations_json, player_counts_csv, client_version, map_format_version, + minimum_revive_version, revive_version, size_bytes, sha256, bundle_key, + thumbnail_size_bytes, thumbnail_sha256, thumbnail_key, created_at_ms) + SELECT ?, revision, status, 'M4JlKsWSY0uH_EdAyFVzCw', creator_ids_json, tags_json, + configurations_json, validations_json, player_counts_csv, client_version, + map_format_version, minimum_revive_version, revive_version, size_bytes, sha256, + replace(bundle_key, + '1a658233-92c5-4b63-87fc-4740c855730b', + '2a658233-92c5-4b63-87fc-4740c855730b'), + NULL, NULL, NULL, created_at_ms + FROM map_versions WHERE map_id = ? AND revision = 1`, + ).bind(secondId, "1a658233-92c5-4b63-87fc-4740c855730b"), + ]); + const tokens = await accessToken(); + const headers = { Authorization: `Bearer ${tokens.accessToken}` }; + const first = await fetchApi("/v1/maps?limit=1", { headers }); + const page = await json<{ items: unknown[]; nextCursor: string }>(first); + expect(page.items).toHaveLength(1); + expect(page.nextCursor).toBeTruthy(); + const next = await fetchApi(`/v1/maps?limit=1&cursor=${encodeURIComponent(page.nextCursor)}`, { headers }); + expect(next.status).toBe(200); + const tamperedCursor = `${page.nextCursor.slice(0, -1)}${page.nextCursor.endsWith("A") ? "B" : "A"}`; + const tampered = await fetchApi(`/v1/maps?limit=1&cursor=${encodeURIComponent(tamperedCursor)}`, { headers }); + expect(tampered.status).toBe(400); + const ambiguous = await fetchApi("/v1//maps", { headers }); + expect(ambiguous.status).toBe(400); + const encoded = await fetchApi("/v1/%6daps", { headers }); + expect(encoded.status).toBe(400); + }); + + it("refuses D1 object-key metadata that could cross an R2 authorization boundary", async () => { + await seed(); + await env.DB.prepare( + "UPDATE map_versions SET bundle_key = 'approved/maps/another-object.ss2level' WHERE map_id = ? AND revision = 1", + ).bind("1a658233-92c5-4b63-87fc-4740c855730b").run(); + const tokens = await accessToken(); + const response = await fetchApi( + "/v1/maps/1a658233-92c5-4b63-87fc-4740c855730b/versions/1/download", + { headers: { Authorization: `Bearer ${tokens.accessToken}` } }, + ); + expect(response.status).toBe(503); + expect((await json<{ error: { code: string } }>(response)).error.code).toBe("object_metadata_mismatch"); + }); + + it("uses Steam activation and fails closed for local setup routes in production", async () => { + const productionEnv: Env = { + ...env, + ENVIRONMENT: "production", + ALLOW_MOCK_AUTH: "true", + PUBLIC_ORIGIN: "https://community.m12labs.net", + }; + const activation = await worker.fetch( + new Request("https://community.m12labs.net/activate"), productionEnv, + ); + expect(activation.status).toBe(200); + expect(await activation.text()).toContain("Continue to Steam"); + const seeded = await worker.fetch(new Request("https://community.m12labs.net/_local/seed", { + method: "POST", headers: { "X-Local-Setup-Secret": env.LOCAL_AUTH_SECRET }, + }), productionEnv); + expect(seeded.status).toBe(404); + }); + + it("never exposes mock routes on a public origin even with local flags", async () => { + const activation = await worker.fetch(new Request("https://community.m12labs.net/activate"), env); + expect(activation.status).toBe(421); + const seeded = await worker.fetch(new Request("https://community.m12labs.net/_local/seed", { + method: "POST", headers: { "X-Local-Setup-Secret": env.LOCAL_AUTH_SECRET }, + }), env); + expect(seeded.status).toBe(421); + }); + + it("fails closed when security-sensitive environment values are invalid", async () => { + const invalidEnv: Env = { ...env, LOCAL_AUTH_SECRET: "short" }; + const response = await worker.fetch(new Request(`${ORIGIN}/health`), invalidEnv); + expect(response.status).toBe(503); + expect((await json<{ error: { code: string } }>(response)).error.code).toBe("temporarily_unavailable"); + }); +}); diff --git a/backend/community-api/tsconfig.json b/backend/community-api/tsconfig.json new file mode 100644 index 0000000..ca4e5bd --- /dev/null +++ b/backend/community-api/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "WebWorker"], + "types": ["node", "@cloudflare/workers-types", "@cloudflare/vitest-pool-workers/types"], + "noEmit": true + }, + "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts", "worker-configuration.d.ts"] +} diff --git a/backend/community-api/vitest.config.ts b/backend/community-api/vitest.config.ts new file mode 100644 index 0000000..abfdaa3 --- /dev/null +++ b/backend/community-api/vitest.config.ts @@ -0,0 +1,29 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +const directory = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + plugins: [ + cloudflareTest(async () => ({ + wrangler: { configPath: path.join(directory, "wrangler.jsonc") }, + miniflare: { + bindings: { + LOCAL_AUTH_SECRET: "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=", + AUTH_SIGNING_SECRET: "YWJjZGVmMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODg=", + TEST_MIGRATIONS: await readD1Migrations(path.join(directory, "migrations")), + }, + }, + })), + ], + test: { + setupFiles: ["./test/apply-migrations.ts"], + coverage: { + provider: "istanbul", + reporter: ["text", "json-summary"], + reportsDirectory: "coverage", + }, + }, +}); diff --git a/backend/community-api/worker-configuration.d.ts b/backend/community-api/worker-configuration.d.ts new file mode 100644 index 0000000..6078c60 --- /dev/null +++ b/backend/community-api/worker-configuration.d.ts @@ -0,0 +1,23 @@ +declare namespace Cloudflare { + interface Env { + DB: D1Database; + MAP_BUCKET: R2Bucket; + VALIDATION_QUEUE: Queue; + ENVIRONMENT: string; + ALLOW_MOCK_AUTH: string; + AUTH_ISSUER: string; + AUTH_AUDIENCE: string; + LOCAL_AUTH_SECRET: string; + AUTH_SIGNING_SECRET: string; + MOCK_STEAM_ID64: string; + PUBLIC_ORIGIN: string; + DOWNLOAD_DAILY_BYTES: string; + DOWNLOAD_CONCURRENCY: string; + AUTH_RATE_LIMITER: RateLimit; + API_RATE_LIMITER: RateLimit; + DOWNLOAD_RATE_LIMITER: RateLimit; + TEST_MIGRATIONS: D1Migration[]; + } +} + +interface Env extends Cloudflare.Env {} diff --git a/backend/community-api/wrangler.jsonc b/backend/community-api/wrangler.jsonc new file mode 100644 index 0000000..b2121b6 --- /dev/null +++ b/backend/community-api/wrangler.jsonc @@ -0,0 +1,62 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "ss2revive-community-api-local", + "main": "src/worker.ts", + "compatibility_date": "2026-08-08", + "workers_dev": false, + "preview_urls": false, + "vars": { + "ENVIRONMENT": "local", + "ALLOW_MOCK_AUTH": "true", + "AUTH_ISSUER": "urn:ss2revive:community:local", + "AUTH_AUDIENCE": "ss2revive-community-client", + "MOCK_STEAM_ID64": "76561198145479980", + "PUBLIC_ORIGIN": "http://127.0.0.1:8787", + "DOWNLOAD_DAILY_BYTES": "2147483648", + "DOWNLOAD_CONCURRENCY": "3" + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "ss2revive-community-local", + "database_id": "00000000-0000-0000-0000-000000000001", + "migrations_dir": "migrations", + "remote": false + } + ], + "r2_buckets": [ + { + "binding": "MAP_BUCKET", + "bucket_name": "ss2revive-community-local", + "remote": false + } + ], + "queues": { + "producers": [ + { + "binding": "VALIDATION_QUEUE", + "queue": "ss2revive-validation-local" + } + ] + }, + "ratelimits": [ + { + "name": "AUTH_RATE_LIMITER", + "namespace_id": "1001", + "simple": { "limit": 10000, "period": 60 } + }, + { + "name": "API_RATE_LIMITER", + "namespace_id": "1002", + "simple": { "limit": 10000, "period": 60 } + }, + { + "name": "DOWNLOAD_RATE_LIMITER", + "namespace_id": "1003", + "simple": { "limit": 10000, "period": 60 } + } + ], + "triggers": { + "crons": ["17 * * * *"] + } +} diff --git a/backend/community-api/wrangler.production.example.jsonc b/backend/community-api/wrangler.production.example.jsonc new file mode 100644 index 0000000..40b232a --- /dev/null +++ b/backend/community-api/wrangler.production.example.jsonc @@ -0,0 +1,73 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "ss2revive-community-api-production", + "main": "src/worker.ts", + "compatibility_date": "2026-08-08", + "workers_dev": false, + "preview_urls": false, + "routes": [ + { + "pattern": "community.m12labs.net", + "custom_domain": true + } + ], + "vars": { + "ENVIRONMENT": "production", + "ALLOW_MOCK_AUTH": "false", + "AUTH_ISSUER": "urn:ss2revive:community:production", + "AUTH_AUDIENCE": "ss2revive-community-client", + "MOCK_STEAM_ID64": "", + "PUBLIC_ORIGIN": "https://community.m12labs.net", + "DOWNLOAD_DAILY_BYTES": "2147483648", + "DOWNLOAD_CONCURRENCY": "3" + }, + "secrets": { + "required": ["AUTH_SIGNING_SECRET"] + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "ss2revive-community-production", + "database_id": "00000000-0000-0000-0000-000000000000", + "migrations_dir": "migrations" + } + ], + "r2_buckets": [ + { + "binding": "MAP_BUCKET", + "bucket_name": "ss2revive-community-production" + } + ], + "queues": { + "producers": [ + { + "binding": "VALIDATION_QUEUE", + "queue": "ss2revive-validation-production" + } + ] + }, + "ratelimits": [ + { + "name": "AUTH_RATE_LIMITER", + "namespace_id": "1101", + "simple": { "limit": 10, "period": 60 } + }, + { + "name": "API_RATE_LIMITER", + "namespace_id": "1102", + "simple": { "limit": 120, "period": 60 } + }, + { + "name": "DOWNLOAD_RATE_LIMITER", + "namespace_id": "1103", + "simple": { "limit": 30, "period": 60 } + } + ], + "triggers": { + "crons": ["17 * * * *"] + }, + "observability": { + "enabled": true, + "head_sampling_rate": 0.1 + } +} diff --git a/backend/community-contracts/package.json b/backend/community-contracts/package.json new file mode 100644 index 0000000..9331221 --- /dev/null +++ b/backend/community-contracts/package.json @@ -0,0 +1,9 @@ +{ + "name": "@ss2revive/community-contracts", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + } +} diff --git a/backend/community-contracts/src/index.ts b/backend/community-contracts/src/index.ts new file mode 100644 index 0000000..5c1045c --- /dev/null +++ b/backend/community-contracts/src/index.ts @@ -0,0 +1,4 @@ +export * from "./level-bundle"; +export * from "./level-code"; +export * from "./types"; +export * from "./validation"; diff --git a/backend/community-contracts/src/level-bundle.ts b/backend/community-contracts/src/level-bundle.ts new file mode 100644 index 0000000..0d1e244 --- /dev/null +++ b/backend/community-contracts/src/level-bundle.ts @@ -0,0 +1,533 @@ +import { levelCodeFromId } from "./level-code"; +import { + MAP_FORMAT_VERSION, + MAX_BUNDLE_BYTES, + MAX_CONTENT_BYTES, + MAX_CREATORS, + MAX_DESCRIPTION_CHARACTERS, + MAX_IMAGE_BYTES, + MAX_MANIFEST_BYTES, + MAX_METADATA_ITEM_CHARACTERS, + MAX_TAGS, + MAX_TITLE_CHARACTERS, +} from "./types"; +import { hasOnlyKeys, isCanonicalUuid, isPlainObject, isSha256 } from "./validation"; + +const BUNDLE_MAGIC = new TextEncoder().encode("SS2REVIVE LEVEL\n"); +const LEVEL_MAGIC = new TextEncoder().encode("Surgeons"); +const KNOWN_ENTRIES = new Set(["asset.json", "level.bin", "level.png", "thumb.png"]); +const MAX_ENTRIES = 16; +const MAX_NAME_BYTES = 64; +const MAX_JSON_DEPTH = 32; +const MAX_INT32 = 2_147_483_647; + +export type BundleValidationCode = + | "bundle_empty" + | "bundle_oversized" + | "bundle_magic_invalid" + | "bundle_version_invalid" + | "bundle_malformed" + | "bundle_too_many_entries" + | "bundle_duplicate_entry" + | "bundle_entry_oversized" + | "bundle_trailing_data" + | "manifest_missing" + | "manifest_invalid" + | "manifest_duplicate_key" + | "manifest_metadata_invalid" + | "content_missing" + | "content_magic_invalid" + | "content_format_invalid" + | "content_checksum_invalid" + | "image_invalid"; + +export class BundleValidationError extends Error { + readonly code: BundleValidationCode; + + constructor(code: BundleValidationCode, message: string) { + super(message); + this.name = "BundleValidationError"; + this.code = code; + } +} + +export interface BundleManifest { + bundleVersion: 2; + id: string; + title: string; + description: string; + createdAt: number; + exportedAt: number; + clientVersion: 29; + contentVersion: number; + reviveVersion: string; + contentSha256: string; + creators: string[]; + tags: string[]; + configurations: unknown[]; + validations: unknown[]; +} + +export interface GameImageInfo { + width: number; + height: number; + format: 1 | 2 | 3 | 4 | 5 | 7; + dataLength: number; + dataOffset: number; +} + +export interface InspectedLevelBundle { + containerVersion: 2; + manifest: BundleManifest; + code: string; + content: Uint8Array; + contentImage?: Uint8Array; + thumbnail?: Uint8Array; + thumbnailInfo?: GameImageInfo; + bundleSha256: string; +} + +export interface InspectBundleOptions { + nowMs?: number; +} + +interface ParsedEntries { + manifest?: Uint8Array; + content?: Uint8Array; + contentImage?: Uint8Array; + thumbnail?: Uint8Array; +} + +function fail(code: BundleValidationCode, message: string): never { + throw new BundleValidationError(code, message); +} + +function equalPrefix(bytes: Uint8Array, expected: Uint8Array): boolean { + if (bytes.length < expected.length) return false; + for (let index = 0; index < expected.length; index += 1) { + if (bytes[index] !== expected[index]) return false; + } + return true; +} + +function entryLimit(name: string): number { + if (name === "asset.json") return MAX_MANIFEST_BYTES; + if (name === "level.bin") return MAX_CONTENT_BYTES; + if (name === "level.png" || name === "thumb.png") return MAX_IMAGE_BYTES; + return MAX_MANIFEST_BYTES; +} + +function parseEntries(bytes: Uint8Array): { containerVersion: 2; entries: ParsedEntries } { + if (bytes.length < BUNDLE_MAGIC.length + 2) fail("bundle_empty", "The bundle is empty or truncated."); + if (bytes.length > MAX_BUNDLE_BYTES) fail("bundle_oversized", "The bundle exceeds 24 MiB."); + if (!equalPrefix(bytes, BUNDLE_MAGIC)) fail("bundle_magic_invalid", "The bundle magic is invalid."); + + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let offset = BUNDLE_MAGIC.length; + const containerVersion = view.getUint16(offset, true); + offset += 2; + if (containerVersion !== 2) { + fail("bundle_version_invalid", "Only clean-slate container version 2 is supported."); + } + + const entries: ParsedEntries = {}; + const known = new Set(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + + for (let entryCount = 0; ; entryCount += 1) { + if (entryCount >= MAX_ENTRIES) fail("bundle_too_many_entries", "The bundle has too many entries."); + if (offset + 2 > bytes.length) fail("bundle_malformed", "The bundle ends inside an entry header."); + const nameLength = view.getUint16(offset, true); + offset += 2; + if (nameLength === 0) break; + if (nameLength > MAX_NAME_BYTES || offset + nameLength + 4 > bytes.length) { + fail("bundle_malformed", "The bundle entry header is malformed."); + } + + let name: string; + try { + name = decoder.decode(bytes.subarray(offset, offset + nameLength)); + } catch { + fail("bundle_malformed", "The bundle entry name is not valid UTF-8."); + } + offset += nameLength; + const length = view.getInt32(offset, true); + offset += 4; + if (length < 0 || length > bytes.length - offset) { + fail("bundle_malformed", "The bundle entry length is invalid."); + } + if (length > entryLimit(name)) { + fail("bundle_entry_oversized", `The ${name || "unnamed"} entry exceeds its limit.`); + } + if (KNOWN_ENTRIES.has(name)) { + if (known.has(name)) fail("bundle_duplicate_entry", `The ${name} entry appears more than once.`); + known.add(name); + } + + if (name === "asset.json") entries.manifest = bytes.slice(offset, offset + length); + else if (name === "level.bin") entries.content = bytes.slice(offset, offset + length); + else if (name === "level.png") entries.contentImage = bytes.slice(offset, offset + length); + else if (name === "thumb.png") entries.thumbnail = bytes.slice(offset, offset + length); + offset += length; + } + + if (offset !== bytes.length) fail("bundle_trailing_data", "The bundle has trailing data."); + return { containerVersion, entries }; +} + +class JsonShapeScanner { + readonly #text: string; + #offset = 0; + duplicateKey = false; + + constructor(text: string) { + this.#text = text; + } + + scan(): boolean { + try { + this.#skipWhitespace(); + this.#value(0); + this.#skipWhitespace(); + return this.#offset === this.#text.length; + } catch { + return false; + } + } + + #value(depth: number): void { + if (depth > MAX_JSON_DEPTH) throw new Error("depth"); + const character = this.#text[this.#offset]; + if (character === "{") this.#object(depth + 1); + else if (character === "[") this.#array(depth + 1); + else if (character === '"') this.#string(); + else if (character === "t") this.#literal("true"); + else if (character === "f") this.#literal("false"); + else if (character === "n") this.#literal("null"); + else this.#number(); + } + + #object(depth: number): void { + this.#offset += 1; + this.#skipWhitespace(); + const keys = new Set(); + if (this.#text[this.#offset] === "}") { + this.#offset += 1; + return; + } + for (;;) { + if (this.#text[this.#offset] !== '"') throw new Error("object key"); + const keyLiteral = this.#string(); + const key = JSON.parse(keyLiteral) as string; + if (keys.has(key)) this.duplicateKey = true; + keys.add(key); + this.#skipWhitespace(); + if (this.#text[this.#offset] !== ":") throw new Error("colon"); + this.#offset += 1; + this.#skipWhitespace(); + this.#value(depth); + this.#skipWhitespace(); + const character = this.#text[this.#offset]; + if (character === "}") { + this.#offset += 1; + return; + } + if (character !== ",") throw new Error("object delimiter"); + this.#offset += 1; + this.#skipWhitespace(); + } + } + + #array(depth: number): void { + this.#offset += 1; + this.#skipWhitespace(); + if (this.#text[this.#offset] === "]") { + this.#offset += 1; + return; + } + for (;;) { + this.#value(depth); + this.#skipWhitespace(); + const character = this.#text[this.#offset]; + if (character === "]") { + this.#offset += 1; + return; + } + if (character !== ",") throw new Error("array delimiter"); + this.#offset += 1; + this.#skipWhitespace(); + } + } + + #string(): string { + const start = this.#offset; + this.#offset += 1; + for (;;) { + const character = this.#text[this.#offset]; + if (character === undefined || character.charCodeAt(0) < 32) throw new Error("string"); + if (character === '"') { + this.#offset += 1; + return this.#text.slice(start, this.#offset); + } + if (character === "\\") { + this.#offset += 1; + const escape = this.#text[this.#offset]; + if (escape === "u") { + const digits = this.#text.slice(this.#offset + 1, this.#offset + 5); + if (!/^[0-9a-fA-F]{4}$/.test(digits)) throw new Error("unicode escape"); + this.#offset += 5; + continue; + } + if (escape === undefined || !'"\\/bfnrt'.includes(escape)) throw new Error("escape"); + } + this.#offset += 1; + } + } + + #number(): void { + const remaining = this.#text.slice(this.#offset); + const match = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(remaining); + if (match === null) throw new Error("number"); + this.#offset += match[0].length; + } + + #literal(value: string): void { + if (!this.#text.startsWith(value, this.#offset)) throw new Error("literal"); + this.#offset += value.length; + } + + #skipWhitespace(): void { + while (/\s/.test(this.#text[this.#offset] ?? "") && this.#offset < this.#text.length) { + const character = this.#text[this.#offset]; + if (character !== " " && character !== "\t" && character !== "\r" && character !== "\n") { + throw new Error("whitespace"); + } + this.#offset += 1; + } + } +} + +function requireInteger(value: unknown, name: string, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): number { + if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) { + fail("manifest_metadata_invalid", `${name} is not a bounded integer.`); + } + return value as number; +} + +function requireString(value: unknown, name: string, maximum: number, allowEmpty: boolean): string { + if (typeof value !== "string" || value.length > maximum || (!allowEmpty && value.trim().length === 0)) { + fail("manifest_metadata_invalid", `${name} is invalid.`); + } + for (const character of value) { + const code = character.codePointAt(0) ?? 0; + if ((code >= 0 && code < 32) || (code >= 127 && code <= 159)) { + fail("manifest_metadata_invalid", `${name} contains control characters.`); + } + } + return value; +} + +function requireStringArray(value: unknown, name: string, maximumItems: number): string[] { + if (!Array.isArray(value) || value.length > maximumItems) { + fail("manifest_metadata_invalid", `${name} has too many entries or is not an array.`); + } + const result: string[] = []; + const seen = new Set(); + for (const item of value) { + const text = requireString(item, name, MAX_METADATA_ITEM_CHARACTERS, false); + if (seen.has(text)) fail("manifest_metadata_invalid", `${name} contains a duplicate entry.`); + seen.add(text); + result.push(text); + } + return result; +} + +function validateStructuredMetadata(configurations: unknown, validations: unknown): { + configurations: unknown[]; + validations: unknown[]; +} { + if (!Array.isArray(configurations) || configurations.length > 8) { + fail("manifest_metadata_invalid", "configurations must be an array with at most 8 entries."); + } + for (const configuration of configurations) { + if (!isPlainObject(configuration)) fail("manifest_metadata_invalid", "A configuration is not an object."); + if (!hasOnlyKeys(configuration, ["numberPlayers", "numberTeams", "levelTeamConfigurations"])) { + fail("manifest_metadata_invalid", "A configuration contains an unsupported field."); + } + requireInteger(configuration.numberPlayers, "numberPlayers", 1, 4); + requireInteger(configuration.numberTeams, "numberTeams", 0, 4); + const teams = configuration.levelTeamConfigurations; + if (teams === undefined) continue; + if (!Array.isArray(teams) || teams.length > 4) { + fail("manifest_metadata_invalid", "levelTeamConfigurations is invalid."); + } + for (const team of teams) { + if (!isPlainObject(team)) fail("manifest_metadata_invalid", "A team configuration is not an object."); + if (!hasOnlyKeys(team, ["objectives", "playersInTeam"])) { + fail("manifest_metadata_invalid", "A team configuration contains an unsupported field."); + } + if (team.objectives !== undefined) requireStringArray(team.objectives, "objectives", 32); + if (team.playersInTeam !== undefined) { + if (!Array.isArray(team.playersInTeam) || team.playersInTeam.length > 4) { + fail("manifest_metadata_invalid", "playersInTeam is invalid."); + } + for (const player of team.playersInTeam) requireInteger(player, "player index", 0, 3); + } + } + } + + if (!Array.isArray(validations) || validations.length > 32) { + fail("manifest_metadata_invalid", "validations must be an array with at most 32 entries."); + } + for (const validation of validations) { + if (!isPlainObject(validation)) fail("manifest_metadata_invalid", "A validation is not an object."); + if (!hasOnlyKeys(validation, ["description"])) { + fail("manifest_metadata_invalid", "A validation contains an unsupported field."); + } + if (validation.description !== undefined) requireString(validation.description, "validation description", 255, true); + } + return { configurations, validations }; +} + +function parseManifest(bytes: Uint8Array, containerVersion: 2, nowMs: number): BundleManifest { + if (bytes.length === 0 || bytes.length > MAX_MANIFEST_BYTES) { + fail("manifest_missing", "The bundle has no readable manifest."); + } + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + fail("manifest_invalid", "The manifest is not valid UTF-8."); + } + const scanner = new JsonShapeScanner(text); + if (!scanner.scan()) fail("manifest_invalid", "The manifest is invalid or nested too deeply."); + if (scanner.duplicateKey) fail("manifest_duplicate_key", "The manifest contains a duplicate object key."); + + let raw: unknown; + try { + raw = JSON.parse(text) as unknown; + } catch { + fail("manifest_invalid", "The manifest is not valid JSON."); + } + if (!isPlainObject(raw)) fail("manifest_invalid", "The manifest root must be an object."); + if (!hasOnlyKeys(raw, [ + "bundleVersion", "id", "title", "description", "createdAt", "exportedAt", + "clientVersion", "contentVersion", "reviveVersion", "contentSha256", + "creators", "tags", "configurations", "validations", + ])) { + fail("manifest_metadata_invalid", "The manifest contains an unsupported field."); + } + + const bundleVersion = requireInteger(raw.bundleVersion, "bundleVersion", 2, 2); + if (bundleVersion !== containerVersion) fail("manifest_metadata_invalid", "Bundle versions disagree."); + if (!isCanonicalUuid(raw.id)) fail("manifest_metadata_invalid", "The map id is not a canonical GUID."); + const title = requireString(raw.title, "title", MAX_TITLE_CHARACTERS, false); + const description = requireString(raw.description, "description", MAX_DESCRIPTION_CHARACTERS, true); + const createdAt = requireInteger(raw.createdAt, "createdAt"); + const exportedAt = requireInteger(raw.exportedAt, "exportedAt"); + if (exportedAt < createdAt || createdAt > nowMs + 86_400_000 || exportedAt > nowMs + 86_400_000) { + fail("manifest_metadata_invalid", "The manifest timestamps are inconsistent."); + } + const clientVersion = requireInteger(raw.clientVersion, "clientVersion", MAP_FORMAT_VERSION, MAP_FORMAT_VERSION); + const contentVersion = requireInteger(raw.contentVersion, "contentVersion", 1, MAX_INT32); + const reviveVersion = requireString(raw.reviveVersion, "reviveVersion", 64, true); + if (!isSha256(raw.contentSha256)) fail("content_checksum_invalid", "contentSha256 is not canonical lowercase SHA-256."); + const creators = requireStringArray(raw.creators, "creators", MAX_CREATORS); + const tags = requireStringArray(raw.tags, "tags", MAX_TAGS); + const structured = validateStructuredMetadata(raw.configurations, raw.validations); + + return { + bundleVersion: bundleVersion as 2, + id: raw.id, + title, + description, + createdAt, + exportedAt, + clientVersion: clientVersion as 29, + contentVersion, + reviveVersion, + contentSha256: raw.contentSha256, + creators, + tags, + configurations: structured.configurations, + validations: structured.validations, + }; +} + +function readBits(bytes: Uint8Array, startBit: number, count: number): number | null { + if (startBit < 0 || count < 0 || count > 31 || startBit + count > bytes.length * 8) return null; + let value = 0; + for (let bit = 0; bit < count; bit += 1) { + const absolute = startBit + bit; + const set = ((bytes[Math.floor(absolute / 8)] ?? 0) >> (absolute % 8)) & 1; + value += set * 2 ** bit; + } + return value; +} + +export function inspectGameImage(bytes: Uint8Array): GameImageInfo { + if (bytes.length < 13 || bytes.length > MAX_IMAGE_BYTES) fail("image_invalid", "The game image size is invalid."); + const width = readBits(bytes, 0, 31); + const height = readBits(bytes, 31, 31); + const format = readBits(bytes, 62, 4); + const dataLength = readBits(bytes, 66, 31); + if (width === null || height === null || format === null || dataLength === null) { + fail("image_invalid", "The game image header is truncated."); + } + const bytesPerPixel = new Map([[1, 1], [2, 2], [3, 3], [4, 4], [5, 4], [7, 2]]).get(format); + if ( + width < 1 || height < 1 || width > 2048 || height > 2048 || width * height > 4 * 1024 * 1024 || + dataLength < 1 || dataLength > MAX_IMAGE_BYTES || bytesPerPixel === undefined || + width * height * bytesPerPixel > dataLength || 13 + dataLength !== bytes.length + ) { + fail("image_invalid", "The game image dimensions, format, or payload length is unsafe."); + } + return { width, height, format: format as GameImageInfo["format"], dataLength, dataOffset: 13 }; +} + +export async function sha256Hex(bytes: Uint8Array): Promise { + const source = new Uint8Array(bytes.byteLength); + source.set(bytes); + const digest = await crypto.subtle.digest("SHA-256", source); + return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join(""); +} + +export async function inspectLevelBundle( + input: ArrayBuffer | Uint8Array, + options: InspectBundleOptions = {}, +): Promise { + const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); + const { containerVersion, entries } = parseEntries(bytes); + if (entries.manifest === undefined) fail("manifest_missing", "The bundle has no asset.json entry."); + if (entries.content === undefined || entries.content.length === 0) fail("content_missing", "The bundle has no level.bin entry."); + if (entries.content.length < 10 || !equalPrefix(entries.content, LEVEL_MAGIC)) { + fail("content_magic_invalid", "level.bin is not a Surgeon Simulator 2 level."); + } + const levelVersion = entries.content[8]! | (entries.content[9]! << 8); + if (levelVersion !== MAP_FORMAT_VERSION) fail("content_format_invalid", "Only map format 29 is accepted."); + + const manifest = parseManifest(entries.manifest, containerVersion, options.nowMs ?? Date.now()); + if (manifest.clientVersion !== levelVersion) { + fail("content_format_invalid", "The declared and embedded map versions disagree."); + } + const contentSha256 = await sha256Hex(entries.content); + if (contentSha256 !== manifest.contentSha256) { + fail("content_checksum_invalid", "level.bin does not match contentSha256."); + } + + let thumbnailInfo: GameImageInfo | undefined; + if (entries.contentImage !== undefined) inspectGameImage(entries.contentImage); + if (entries.thumbnail !== undefined) thumbnailInfo = inspectGameImage(entries.thumbnail); + + const result: InspectedLevelBundle = { + containerVersion, + manifest, + code: levelCodeFromId(manifest.id), + content: entries.content, + bundleSha256: await sha256Hex(bytes), + }; + if (entries.contentImage !== undefined) result.contentImage = entries.contentImage; + if (entries.thumbnail !== undefined) result.thumbnail = entries.thumbnail; + if (thumbnailInfo !== undefined) result.thumbnailInfo = thumbnailInfo; + return result; +} diff --git a/backend/community-contracts/src/level-code.ts b/backend/community-contracts/src/level-code.ts new file mode 100644 index 0000000..6e35774 --- /dev/null +++ b/backend/community-contracts/src/level-code.ts @@ -0,0 +1,34 @@ +import { isCanonicalUuid } from "./validation"; + +const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +function guidBytes(levelId: string): Uint8Array | null { + if (!isCanonicalUuid(levelId)) return null; + const hex = levelId.replaceAll("-", ""); + const raw = new Uint8Array(16); + for (let index = 0; index < raw.length; index += 1) { + raw[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); + } + return new Uint8Array([ + raw[3]!, raw[2]!, raw[1]!, raw[0]!, + raw[5]!, raw[4]!, raw[7]!, raw[6]!, + raw[8]!, raw[9]!, raw[10]!, raw[11]!, raw[12]!, raw[13]!, raw[14]!, raw[15]!, + ]); +} + +export function levelCodeFromId(levelId: string): string { + const bytes = guidBytes(levelId); + if (bytes === null) return ""; + let output = ""; + for (let index = 0; index < bytes.length; index += 3) { + const a = bytes[index] ?? 0; + const b = bytes[index + 1] ?? 0; + const c = bytes[index + 2] ?? 0; + const remaining = bytes.length - index; + output += BASE64[(a >> 2) & 63]; + output += BASE64[((a & 3) << 4) | (b >> 4)]; + output += remaining > 1 ? BASE64[((b & 15) << 2) | (c >> 6)] : "="; + output += remaining > 2 ? BASE64[c & 63] : "="; + } + return output.slice(0, 22).replaceAll("/", "_").replaceAll("+", "-"); +} diff --git a/backend/community-contracts/src/types.ts b/backend/community-contracts/src/types.ts new file mode 100644 index 0000000..34b07cf --- /dev/null +++ b/backend/community-contracts/src/types.ts @@ -0,0 +1,70 @@ +export const API_VERSION = 1 as const; +export const MAP_FORMAT_VERSION = 29 as const; +export const MAX_BUNDLE_BYTES = 24 * 1024 * 1024; +export const MAX_CONTENT_BYTES = 4_096_000; +export const MAX_IMAGE_BYTES = 8 * 1024 * 1024; +export const MAX_MANIFEST_BYTES = 1024 * 1024; +export const MAX_TITLE_CHARACTERS = 128; +export const MAX_DESCRIPTION_CHARACTERS = 2048; +export const MAX_CREATORS = 4; +export const MAX_TAGS = 32; +export const MAX_METADATA_ITEM_CHARACTERS = 128; +export const MAX_PAGE_SIZE = 50; + +export interface ApiErrorBody { + error: { + code: string; + message: string; + requestId: string; + }; +} + +export interface MockSessionResponse { + accessToken: string; + expiresAtUtc: string; + tokenType: "Bearer"; +} + +export interface MapThumbnail { + url: string; + sizeBytes: number; + sha256: string; +} + +export interface CommunityMap { + id: string; + code: string; + revision: number; + title: string; + description: string; + creatorIds: string[]; + tags: string[]; + createdAtMs: number; + updatedAtMs: number; + clientVersion: 29; + mapFormatVersion: 29; + minimumReviveVersion: string; + reviveVersion?: string; + sizeBytes: number; + sha256: string; + configurations: unknown[]; + validations: unknown[]; + downloadUrl: string; + thumbnail?: MapThumbnail; +} + +export interface MapPage { + items: CommunityMap[]; + nextCursor: string | null; +} + +export interface MapVersionsResponse { + mapId: string; + versions: CommunityMap[]; +} + +export interface AuthPrincipal { + steamId64: string; + sessionId: string; + scopes: readonly string[]; +} diff --git a/backend/community-contracts/src/validation.ts b/backend/community-contracts/src/validation.ts new file mode 100644 index 0000000..34e3154 --- /dev/null +++ b/backend/community-contracts/src/validation.ts @@ -0,0 +1,82 @@ +import { + MAX_DESCRIPTION_CHARACTERS, + MAX_METADATA_ITEM_CHARACTERS, + MAX_TITLE_CHARACTERS, +} from "./types"; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const SHA256_PATTERN = /^[0-9a-f]{64}$/; +const SEMVER_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/; +const MAX_UINT64 = 18_446_744_073_709_551_615n; + +export function isCanonicalUuid(value: unknown): value is string { + return typeof value === "string" && UUID_PATTERN.test(value); +} + +export function isSha256(value: unknown): value is string { + return typeof value === "string" && SHA256_PATTERN.test(value); +} + +export function isSemanticVersion(value: unknown): value is string { + return typeof value === "string" && value.length <= 29 && SEMVER_PATTERN.test(value); +} + +export function isSteamId64(value: unknown): value is string { + if (typeof value !== "string" || !/^[1-9][0-9]{16,19}$/.test(value)) return false; + try { + return BigInt(value) <= MAX_UINT64; + } catch { + return false; + } +} + +export function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value) as object | null; + return prototype === Object.prototype || prototype === null; +} + +export function hasOnlyKeys(value: Record, keys: readonly string[]): boolean { + const allowed = new Set(keys); + return Object.keys(value).every((key) => allowed.has(key)); +} + +export function cleanText(value: unknown, maximum: number): string { + if (typeof value !== "string") return ""; + let result = ""; + for (const character of value) { + if (result.length >= maximum) break; + const code = character.codePointAt(0) ?? 0; + if (character === "\r" || character === "\n" || character === "\t") { + result += " "; + } else if ((code >= 0 && code < 32) || (code >= 127 && code <= 159)) { + continue; + } else { + result += character; + } + } + return result.trim(); +} + +export function cleanTitle(value: unknown): string { + return cleanText(value, MAX_TITLE_CHARACTERS) || "Untitled level"; +} + +export function cleanDescription(value: unknown): string { + return cleanText(value, MAX_DESCRIPTION_CHARACTERS); +} + +export function cleanMetadataItem(value: unknown): string { + return cleanText(value, MAX_METADATA_ITEM_CHARACTERS); +} + +export function parseBoundedPositiveInteger( + value: string | null, + fallback: number, + maximum: number, +): number | null { + if (value === null || value === "") return fallback; + if (!/^[1-9][0-9]*$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed <= maximum ? parsed : null; +} diff --git a/backend/community-contracts/tsconfig.json b/backend/community-contracts/tsconfig.json new file mode 100644 index 0000000..15d4fd6 --- /dev/null +++ b/backend/community-contracts/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "WebWorker"], + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..544510c --- /dev/null +++ b/backend/package.json @@ -0,0 +1,14 @@ +{ + "name": "ss2revive-backend", + "private": true, + "packageManager": "pnpm@11.16.0", + "scripts": { + "build": "pnpm --filter @ss2revive/community-api build", + "check": "pnpm --filter @ss2revive/community-api check", + "test": "pnpm --filter @ss2revive/community-api test", + "test:coverage": "pnpm --filter @ss2revive/community-api test:coverage", + "security:secrets": "node scripts/secret-scan.mjs", + "security:audit": "pnpm audit --audit-level high", + "security": "pnpm run security:secrets && pnpm run security:audit" + } +} diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml new file mode 100644 index 0000000..90509a8 --- /dev/null +++ b/backend/pnpm-lock.yaml @@ -0,0 +1,2269 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + community-api: + dependencies: + '@ss2revive/community-contracts': + specifier: workspace:* + version: link:../community-contracts + devDependencies: + '@cloudflare/vitest-pool-workers': + specifier: 0.20.3 + version: 0.20.3(@cloudflare/workers-types@5.20260804.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10) + '@cloudflare/workers-types': + specifier: 5.20260804.1 + version: 5.20260804.1 + '@types/node': + specifier: 26.2.0 + version: 26.2.0 + '@vitest/coverage-istanbul': + specifier: 4.1.10 + version: 4.1.10(supports-color@10.2.2)(vitest@4.1.10) + typescript: + specifier: 7.0.2 + version: 7.0.2 + vitest: + specifier: 4.1.10 + version: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)) + wrangler: + specifier: 4.120.0 + version: 4.120.0(@cloudflare/workers-types@5.20260804.1) + + community-contracts: {} + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/vitest-pool-workers@0.20.3': + resolution: {integrity: sha512-aCMvM5zQ3MTz8SorSZB6ZxjVK2Gof6UhIc2Z1z9zbX/obucSYwDUkx8A0MUOod4I7clfB0QLarKBjRdIczK3vg==} + peerDependencies: + '@vitest/runner': ^4.1.0 + '@vitest/snapshot': ^4.1.0 + vitest: ^4.1.0 + + '@cloudflare/workerd-darwin-64@1.20260801.1': + resolution: {integrity: sha512-wuJWbXpKvncJi1P0GKS+iYpN5tHdb7JPJJ/+6ZQe8zzovHHVMkLJPNBsgWpqeUhpM3g9qTwEKd2rglNKejuh5A==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260801.1': + resolution: {integrity: sha512-kwoZiTpnhNrF3+APx84Q/oAqvJ3sU9yefGagwm/ASaH/2W19x0vghkW/r4qCoHCK0WW7EPugZ+aXjgPMRtlq1Q==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260801.1': + resolution: {integrity: sha512-r0vAxCZH+Jih9Unm1yoyiByPNWNgawcKciOHDm5Q37ZVGOkKLsT9AtLe3yLSaul76WrKqtf+JP2n0WW32VBLJg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260801.1': + resolution: {integrity: sha512-zWgpdZtSozvIgzQNmQiDSF8yEOQJUkRAWNsDXXzAAoy+fCn8YUoSibj3mpFSbZRvbUldeBEaW6SCdC2VEMkhNQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260801.1': + resolution: {integrity: sha512-2oQz+Ksu4ji6e/+ZoYX+tWQEcxAii2p7l+iR8kx48W1llMalaufAsmVxTlk+3/vrM7D3/2c0iK448e0UQTcIMg==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@5.20260804.1': + resolution: {integrity: sha512-B1dwxpN6e5RZXZkE5zpZj+ooNeNZ1mwavLIyHDYe10ojhlGTwDfe8sAl7R1mMXc1cyIsbr+jKVdvmMEyVcdTdg==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.2': + resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.2': + resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.2': + resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.1': + resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.1': + resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.1': + resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.1': + resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.1': + resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.1': + resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.1': + resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.1': + resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.2': + resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.2': + resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.2': + resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.2': + resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.2': + resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.2': + resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.2': + resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.2': + resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.2': + resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.2': + resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.2': + resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.2': + resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.2': + resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@oxc-project/types@0.143.0': + resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@rolldown/binding-android-arm64@1.2.3': + resolution: {integrity: sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.3': + resolution: {integrity: sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.3': + resolution: {integrity: sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.3': + resolution: {integrity: sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.3': + resolution: {integrity: sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.3': + resolution: {integrity: sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.3': + resolution: {integrity: sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.3': + resolution: {integrity: sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.3': + resolution: {integrity: sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.3': + resolution: {integrity: sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.3': + resolution: {integrity: sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.3': + resolution: {integrity: sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.3': + resolution: {integrity: sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.3': + resolution: {integrity: sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.23': + resolution: {integrity: sha512-iRoq6i6JDJP6Mt2A5JaPvzw0pgYHH6k92ij+yXiTrB7T2y9N789aWE3EHWj/5ztlJBokcCBja3iYLVdu5wgnkg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitest/coverage-istanbul@4.1.10': + resolution: {integrity: sha512-AyNJ5pQRFqCX7pwB9PSTmoVKPaZ4H5IEVJfJsT+q1DYkXvZMEFYgJlyk5sfStmt9rVYRyYYRRsuBeImCOc39ww==} + peerDependencies: + vitest: 4.1.10 + + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + + baseline-browser-mapping@2.11.12: + resolution: {integrity: sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==} + engines: {node: '>=6.0.0'} + hasBin: true + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001809: + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + cjs-module-lexer@1.2.3: + resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + electron-to-chromium@1.5.402: + resolution: {integrity: sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + miniflare@5.20260801.1-alpha: + resolution: {integrity: sha512-BHPVzIDA6mbx7LefxpvkXW7DHx9FKB9GorZatbnrrFTt3CVMU8zuUpbgyCuebwDKcTTOZos43ta8GQ0eMVEpxA==} + engines: {node: '>=22.0.0'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + engines: {node: '>=18'} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.2.3: + resolution: {integrity: sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.35.2: + resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} + engines: {node: '>=20.9.0'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + update-browserslist-db@1.3.0: + resolution: {integrity: sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vite@8.2.1: + resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.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 + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + 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 + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + workerd@1.20260801.1: + resolution: {integrity: sha512-/g9JGTyqnHtoIscpBHqKD8swE2V4StBs2i69PmLiOhH45OP95jCFICl4F1hKlgN57rqfni5LiCitttoX5OOkVA==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.120.0: + resolution: {integrity: sha512-cBmu/MeaB/fPacC0JpATs4duTOCagBxrZo+vBzuTX06tLzwSyAHE1drlHUZ8rP0VqVz1fy3ReGYTiHdKkoHltg==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20260801.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + 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 + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7(supports-color@10.2.2)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@10.2.2) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.7 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': + dependencies: + '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8(supports-color@10.2.2)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': + optional: true + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260801.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260801.1 + + '@cloudflare/vitest-pool-workers@0.20.3(@cloudflare/workers-types@5.20260804.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10)': + dependencies: + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + cjs-module-lexer: 1.2.3 + esbuild: 0.28.1 + miniflare: 5.20260801.1-alpha + vitest: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)) + wrangler: 4.120.0(@cloudflare/workers-types@5.20260804.1) + zod: 4.4.3 + transitivePeerDependencies: + - '@cloudflare/workers-types' + - bufferutil + - utf-8-validate + + '@cloudflare/workerd-darwin-64@1.20260801.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260801.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260801.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260801.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260801.1': + optional: true + + '@cloudflare/workers-types@5.20260804.1': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.1 + optional: true + + '@img/sharp-darwin-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.1 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.1': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.1': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + optional: true + + '@img/sharp-linux-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.1 + optional: true + + '@img/sharp-linux-arm@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.1 + optional: true + + '@img/sharp-linux-ppc64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.1 + optional: true + + '@img/sharp-linux-riscv64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.1 + optional: true + + '@img/sharp-linux-s390x@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.1 + optional: true + + '@img/sharp-linux-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + optional: true + + '@img/sharp-wasm32@0.35.2': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-win32-arm64@0.35.2': + optional: true + + '@img/sharp-win32-ia32@0.35.2': + optional: true + + '@img/sharp-win32-x64@0.35.2': + optional: true + + '@istanbuljs/schema@0.1.6': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@oxc-project/types@0.143.0': {} + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@rolldown/binding-android-arm64@1.2.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.3': + optional: true + + '@rolldown/binding-darwin-x64@1.2.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.3': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.23': {} + + '@standard-schema/spec@1.1.0': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@26.2.0': + dependencies: + undici-types: 8.3.0 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitest/coverage-istanbul@4.1.10(supports-color@10.2.2)(vitest@4.1.10)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@istanbuljs/schema': 0.1.6 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.4 + obug: 2.1.4 + tinyrainbow: 3.1.1 + vitest: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)) + transitivePeerDependencies: + - supports-color + + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)) + optional: true + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + optional: true + + baseline-browser-mapping@2.11.12: {} + + blake3-wasm@2.1.5: {} + + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.12 + caniuse-lite: 1.0.30001809 + electron-to-chromium: 1.5.402 + node-releases: 2.0.53 + update-browserslist-db: 1.3.0(browserslist@4.28.7) + + caniuse-lite@1.0.30001809: {} + + chai@6.2.2: {} + + cjs-module-lexer@1.2.3: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + debug@4.4.3(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + + detect-libc@2.1.2: {} + + electron-to-chromium@1.5.402: {} + + error-stack-parser-es@1.0.5: {} + + es-module-lexer@2.3.1: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escalade@3.2.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + js-tokens@10.0.0: + optional: true + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + kleur@4.1.5: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + 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 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.4: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + miniflare@5.20260801.1-alpha: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.2 + undici: 7.29.0 + workerd: 1.20260801.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + node-releases@2.0.53: {} + + obug@2.1.4: {} + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.2.3: + dependencies: + '@oxc-project/types': 0.143.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.3 + '@rolldown/binding-darwin-arm64': 1.2.3 + '@rolldown/binding-darwin-x64': 1.2.3 + '@rolldown/binding-freebsd-x64': 1.2.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.3 + '@rolldown/binding-linux-arm64-gnu': 1.2.3 + '@rolldown/binding-linux-arm64-musl': 1.2.3 + '@rolldown/binding-linux-ppc64-gnu': 1.2.3 + '@rolldown/binding-linux-s390x-gnu': 1.2.3 + '@rolldown/binding-linux-x64-gnu': 1.2.3 + '@rolldown/binding-linux-x64-musl': 1.2.3 + '@rolldown/binding-openharmony-arm64': 1.2.3 + '@rolldown/binding-win32-arm64-msvc': 1.2.3 + '@rolldown/binding-win32-x64-msvc': 1.2.3 + + semver@6.3.1: {} + + semver@7.8.5: {} + + sharp@0.35.2: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.2 + '@img/sharp-darwin-x64': 0.35.2 + '@img/sharp-freebsd-wasm32': 0.35.2 + '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-linux-arm': 0.35.2 + '@img/sharp-linux-arm64': 0.35.2 + '@img/sharp-linux-ppc64': 0.35.2 + '@img/sharp-linux-riscv64': 0.35.2 + '@img/sharp-linux-s390x': 0.35.2 + '@img/sharp-linux-x64': 0.35.2 + '@img/sharp-linuxmusl-arm64': 0.35.2 + '@img/sharp-linuxmusl-x64': 0.35.2 + '@img/sharp-webcontainers-wasm32': 0.35.2 + '@img/sharp-win32-arm64': 0.35.2 + '@img/sharp-win32-ia32': 0.35.2 + '@img/sharp-win32-x64': 0.35.2 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + supports-color@10.2.2: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + tslib@2.8.1: + optional: true + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@8.3.0: {} + + undici@7.29.0: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + update-browserslist-db@1.3.0(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + + vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.2.0 + esbuild: 0.28.1 + fsevents: 2.3.3 + + vitest@4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)) + '@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.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.2.0 + '@vitest/coverage-istanbul': 4.1.10(supports-color@10.2.2)(vitest@4.1.10) + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + workerd@1.20260801.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260801.1 + '@cloudflare/workerd-darwin-arm64': 1.20260801.1 + '@cloudflare/workerd-linux-64': 1.20260801.1 + '@cloudflare/workerd-linux-arm64': 1.20260801.1 + '@cloudflare/workerd-windows-64': 1.20260801.1 + + wrangler@4.120.0(@cloudflare/workers-types@5.20260804.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260801.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 5.20260801.1-alpha + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260801.1 + optionalDependencies: + '@cloudflare/workers-types': 5.20260804.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.21.0: {} + + yallist@3.1.1: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.23 + cookie: 1.1.1 + youch-core: 0.3.3 + + zod@4.4.3: {} diff --git a/backend/pnpm-workspace.yaml b/backend/pnpm-workspace.yaml new file mode 100644 index 0000000..29e69ab --- /dev/null +++ b/backend/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +packages: + - community-contracts + - community-api +allowBuilds: + esbuild: true + workerd: true +confirmModulesPurge: false diff --git a/backend/scripts/secret-scan.mjs b/backend/scripts/secret-scan.mjs new file mode 100644 index 0000000..8e29d3b --- /dev/null +++ b/backend/scripts/secret-scan.mjs @@ -0,0 +1,42 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; + +const root = path.resolve(import.meta.dirname, ".."); +const ignoredDirectories = new Set(["node_modules", ".wrangler", "coverage", "dist", ".git"]); +const ignoredFiles = new Set(["pnpm-lock.yaml", "secret-scan.mjs", ".dev.vars"]); +const findings = []; +const patterns = [ + ["private key", /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/], + ["AWS-style access key", /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/], + ["Cloudflare API token assignment", /\b(?:CLOUDFLARE_API_TOKEN|CF_API_TOKEN)\s*=\s*[^\s<{][^\r\n]*/i], + ["Access client secret assignment", /\bCF_ACCESS_CLIENT_SECRET\s*=\s*[^\s<{][^\r\n]*/i], + ["backend secret assignment", /\b(?:LOCAL_AUTH_SECRET|AUTH_SIGNING_SECRET|STEAM_API_KEY|R2_ACCESS_KEY_ID|R2_SECRET_ACCESS_KEY)\s*=\s*[A-Za-z0-9+/_-]{32,}={0,2}(?![A-Za-z0-9+/_=-])/i], + ["GitHub token", /\bgh(?:p|o|u|s|r)_[A-Za-z0-9]{30,}\b/], +]; + +async function walk(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.isDirectory() && ignoredDirectories.has(entry.name)) continue; + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + await walk(fullPath); + continue; + } + if (!entry.isFile() || ignoredFiles.has(entry.name)) continue; + const relative = path.relative(root, fullPath); + const text = await readFile(fullPath, "utf8").catch(() => null); + if (text === null) continue; + for (const [label, pattern] of patterns) { + if (pattern.test(text)) findings.push(`${relative}: ${label}`); + } + } +} + +await walk(root); +if (findings.length > 0) { + console.error("Potential committed secrets found:\n" + findings.join("\n")); + process.exitCode = 1; +} else { + console.log("Secret scan passed."); +} diff --git a/backend/tsconfig.base.json b/backend/tsconfig.base.json new file mode 100644 index 0000000..a6b574e --- /dev/null +++ b/backend/tsconfig.base.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "useUnknownInCatchVariables": true, + "verbatimModuleSyntax": true, + "resolveJsonModule": true, + "skipLibCheck": true + } +} diff --git a/catalog/catalog-v1.schema.json b/catalog/catalog-v1.schema.json new file mode 100644 index 0000000..6c4bfb5 --- /dev/null +++ b/catalog/catalog-v1.schema.json @@ -0,0 +1,159 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SS2Revive static map catalog v1", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "generatedAtUtc", "maps"], + "properties": { + "schemaVersion": { + "const": 1 + }, + "generatedAtUtc": { + "type": "string", + "format": "date-time", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" + }, + "maps": { + "type": "array", + "maxItems": 2000, + "items": { + "$ref": "#/$defs/map" + } + } + }, + "$defs": { + "shortMetadata": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "map": { + "type": "object", + "additionalProperties": false, + "dependentRequired": { + "thumbnailKey": ["thumbnailSizeBytes", "thumbnailSha256"], + "thumbnailSizeBytes": ["thumbnailKey", "thumbnailSha256"], + "thumbnailSha256": ["thumbnailKey", "thumbnailSizeBytes"] + }, + "required": [ + "id", + "code", + "revision", + "title", + "description", + "creatorIds", + "tags", + "createdAtMs", + "updatedAtMs", + "clientVersion", + "mapFormatVersion", + "minimumReviveVersion", + "sizeBytes", + "sha256", + "bundleKey", + "configurations", + "validations" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "code": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{22}$" + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "description": { + "type": "string", + "maxLength": 2048 + }, + "creatorIds": { + "type": "array", + "maxItems": 4, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/shortMetadata" + } + }, + "tags": { + "type": "array", + "maxItems": 32, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/shortMetadata" + } + }, + "createdAtMs": { + "type": "integer", + "minimum": 0 + }, + "updatedAtMs": { + "type": "integer", + "minimum": 0 + }, + "clientVersion": { + "const": 29 + }, + "mapFormatVersion": { + "const": 29 + }, + "minimumReviveVersion": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$", + "maxLength": 29 + }, + "reviveVersion": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "sizeBytes": { + "type": "integer", + "minimum": 1, + "maximum": 25165824 + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "bundleKey": { + "type": "string", + "maxLength": 256, + "pattern": "^map-[0-9a-f-]{36}-r[1-9][0-9]*-[0-9a-f]{64}\\.ss2level$" + }, + "thumbnailKey": { + "type": "string", + "maxLength": 256, + "pattern": "^thumb-[0-9a-f-]{36}-r[1-9][0-9]*-[0-9a-f]{64}\\.bin$" + }, + "thumbnailSizeBytes": { + "type": "integer", + "minimum": 1, + "maximum": 8388608 + }, + "thumbnailSha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "configurations": { + "type": "array", + "maxItems": 8 + }, + "validations": { + "type": "array", + "maxItems": 32 + } + } + } + } +} diff --git a/catalog/catalog.json b/catalog/catalog.json new file mode 100644 index 0000000..6c760fd --- /dev/null +++ b/catalog/catalog.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": 1, + "generatedAtUtc": "2026-01-01T00:00:00Z", + "maps": [] +} diff --git a/src/SS2Revive/CommunityCatalogClient.cs b/src/SS2Revive/CommunityCatalogClient.cs new file mode 100644 index 0000000..d1f343f --- /dev/null +++ b/src/SS2Revive/CommunityCatalogClient.cs @@ -0,0 +1,462 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using SS2ReviveData; + +namespace SS2Revive +{ + /// + /// Read-only client for a curated static map catalogue. There is no upload endpoint, account, + /// or executable service: one bounded JSON file and its relative objects can live on GitHub + /// Pages, a release CDN, R2, or any ordinary HTTPS host. + /// + internal static class CommunityCatalogClient + { + internal const string KeyPrefix = "ss2revive-catalog/"; + + private const int RequestTimeoutMs = 10000; + private const long MaxCacheBytes = 128L * 1024 * 1024; + private const int MaxCacheFiles = 128; + + private static readonly object Gate = new object(); + private static readonly object ObjectCacheGate = new object(); + private static readonly TimeSpan RefreshInterval = TimeSpan.FromMinutes(10); + + private static UgcStore _store; + private static Uri _catalogUri; + private static Uri _objectsBaseUri; + private static string _catalogCacheFile; + private static string _objectCacheDirectory; + private static CommunityCatalog _catalog; + private static bool _refreshing; + private static DateTime _lastRefreshUtc = DateTime.MinValue; + + internal static bool Enabled => _catalogUri != null; + + internal static void Initialise(UgcStore store, string configuredUrl) + { + _store = store; + Uri catalogUri; + if (store == null || string.IsNullOrWhiteSpace(configuredUrl)) return; + if (!Uri.TryCreate(configuredUrl.Trim(), UriKind.Absolute, out catalogUri) + || !string.Equals(catalogUri.Scheme, Uri.UriSchemeHttps, + StringComparison.OrdinalIgnoreCase) + || !string.IsNullOrEmpty(catalogUri.UserInfo) + || !string.IsNullOrEmpty(catalogUri.Fragment) + || !string.IsNullOrEmpty(catalogUri.Query)) + { + Plugin.Log.LogWarning("Community catalogue URL must be an absolute HTTPS URL " + + "without a query or fragment; community browsing is disabled."); + return; + } + + try + { + _catalogUri = catalogUri; + _objectsBaseUri = new Uri(catalogUri, "."); + var root = Path.GetDirectoryName(store.Root) ?? store.Root; + var cacheRoot = Path.Combine(root, "catalog-cache"); + _objectCacheDirectory = Path.Combine(cacheRoot, "objects"); + Directory.CreateDirectory(_objectCacheDirectory); + _catalogCacheFile = Path.Combine(cacheRoot, + "catalog-" + Sha256Hex(Encoding.UTF8.GetBytes(catalogUri.AbsoluteUri)).Substring(0, 16) + + ".json"); + Plugin.Log.LogInfo("Community catalogue enabled from " + catalogUri.GetLeftPart(UriPartial.Path)); + EnsureRefresh(true); + } + catch (Exception ex) + { + _catalogUri = null; + _objectsBaseUri = null; + Plugin.Log.LogWarning("Could not initialise the community catalogue: " + ex.Message); + } + } + + /// + /// Returns every matching entry. Paging happens after local and remote results are merged, + /// otherwise a page from each source would produce duplicates and incorrect page counts. + /// + internal static List Search(UgcQuery query) + { + EnsureRefresh(false); + CommunityCatalog snapshot; + lock (Gate) snapshot = _catalog; + var result = snapshot == null + ? new List() + : snapshot.Search(query); + for (var i = result.Count - 1; i >= 0; i--) + { + if (CommunityCatalog.CompatibilityError(result[i], Plugin.PluginVersion) != null) + result.RemoveAt(i); + } + return result; + } + + internal static string ContentKey(CommunityCatalogEntry entry) => + KeyPrefix + entry.Id + "/" + entry.Revision + "/bundle"; + + internal static string ThumbnailKey(CommunityCatalogEntry entry) => + string.IsNullOrEmpty(entry.ThumbnailKey) + ? string.Empty + : KeyPrefix + entry.Id + "/" + entry.Revision + "/thumbnail"; + + internal static bool OwnsKey(string key) => + !string.IsNullOrEmpty(key) && key.StartsWith(KeyPrefix, StringComparison.Ordinal); + + /// Starts a bounded worker-thread fetch and always completes on Unity's thread. + internal static void ReadKey(string key, Action succeeded, Action failed) + { + CommunityCatalogEntry entry; + bool thumbnail; + if (!TryResolveKey(key, out entry, out thumbnail)) + { + Dispatcher.NextFrame(failed); + return; + } + + ThreadPool.QueueUserWorkItem(delegate + { + byte[] result = null; + try + { + result = thumbnail ? ReadThumbnail(entry) : ReadAndInstallBundle(entry); + } + catch (Exception ex) + { + Plugin.Log.LogWarning("Community map download failed for " + entry.Id + ": " + + ex.Message); + } + + Dispatcher.NextFrame(() => + { + if (result != null) + { + if (succeeded != null) succeeded(result); + } + else if (failed != null) failed(); + }); + }); + } + + private static byte[] ReadThumbnail(CommunityCatalogEntry entry) + { + var bytes = ReadObject(entry.ThumbnailKey, entry.ThumbnailSizeBytes, + entry.ThumbnailSha256, LevelBundle.MaxImageBytes, ".image"); + if (!LevelBundle.IsSafeGameImage(bytes)) + throw new InvalidDataException("The thumbnail is not a safe game image."); + return bytes; + } + + private static byte[] ReadAndInstallBundle(CommunityCatalogEntry entry) + { + var incompatibility = CommunityCatalog.CompatibilityError(entry, Plugin.PluginVersion); + if (incompatibility != null) throw new InvalidDataException(incompatibility); + var bytes = ReadObject(entry.BundleKey, entry.SizeBytes, entry.Sha256, + LevelBundle.MaxBundleBytes, ".bundle"); + string error; + var bundle = LevelBundle.Unpack(bytes, out error); + if (bundle == null) throw new InvalidDataException(error ?? "The level bundle is invalid."); + if (!string.Equals(bundle.Id, entry.Id, StringComparison.OrdinalIgnoreCase) + || bundle.ContentVersion != entry.Revision || bundle.ClientVersion != entry.ClientVersion) + { + throw new InvalidDataException("The bundle does not match its catalogue entry."); + } + + var prototype = new UgcLevelRecord + { + Id = bundle.Id, + Title = bundle.Title, + Description = bundle.Description, + CreatorIds = new List(bundle.CreatorIds), + Tags = new List(bundle.Tags), + CreatedAtMs = bundle.CreatedAtMs, + Configurations = bundle.Configurations, + Validations = bundle.Validations, + }; + UgcInstallOutcome outcome; + var installed = _store.Install(prototype, bundle.ClientVersion, bundle.ContentVersion, + bundle.ExportedAtMs, bundle.Content, bundle.ContentImage, + bundle.Thumbnail, out outcome, out error); + if (installed == null || outcome == UgcInstallOutcome.Conflict + || outcome == UgcInstallOutcome.Failed) + { + throw new InvalidDataException(error ?? "The level could not be installed."); + } + + var latest = installed.LatestContent(); + var content = latest == null ? null : _store.ReadKey(latest.Key); + if (content == null) throw new IOException("The installed level data could not be read back."); + Plugin.Log.LogInfo("Community map " + entry.Id + " is ready (" + outcome + ")."); + return content; + } + + private static byte[] ReadObject(string objectKey, long expectedBytes, string expectedSha, + int maximumBytes, string suffix) + { + Uri uri; + if (!TryResolveObjectUri(objectKey, out uri)) + throw new InvalidDataException("The catalogue contains an unsafe object key."); + + var cached = Path.Combine(_objectCacheDirectory, expectedSha + suffix); + byte[] bytes; + lock (ObjectCacheGate) bytes = ReadCacheFile(cached, maximumBytes); + if (IsExpected(bytes, expectedBytes, expectedSha)) return bytes; + + bytes = Download(uri, maximumBytes); + if (!IsExpected(bytes, expectedBytes, expectedSha)) + throw new InvalidDataException("The downloaded object does not match its size or SHA-256."); + lock (ObjectCacheGate) + { + var wonRace = ReadCacheFile(cached, maximumBytes); + if (IsExpected(wonRace, expectedBytes, expectedSha)) return wonRace; + WriteAtomic(cached, bytes); + EvictObjectCache(); + } + return bytes; + } + + private static bool TryResolveObjectUri(string key, out Uri uri) + { + uri = null; + if (_objectsBaseUri == null || !CommunityCatalog.IsSafeObjectKey(key)) return false; + Uri candidate; + if (!Uri.TryCreate(_objectsBaseUri, key, out candidate)) return false; + if (!string.Equals(candidate.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + || !string.Equals(candidate.Host, _objectsBaseUri.Host, + StringComparison.OrdinalIgnoreCase) + || candidate.Port != _objectsBaseUri.Port) return false; + uri = candidate; + return true; + } + + private static bool TryResolveKey(string key, out CommunityCatalogEntry entry, + out bool thumbnail) + { + entry = null; + thumbnail = false; + if (!OwnsKey(key)) return false; + var parts = key.Substring(KeyPrefix.Length).Split('/'); + Guid id; + int revision; + if (parts.Length != 3 || !Guid.TryParse(parts[0], out id) + || !int.TryParse(parts[1], out revision) || revision < 1 + || (parts[2] != "bundle" && parts[2] != "thumbnail")) return false; + + CommunityCatalog snapshot; + lock (Gate) snapshot = _catalog; + if (snapshot == null) return false; + for (var i = 0; i < snapshot.Entries.Count; i++) + { + var candidate = snapshot.Entries[i]; + if (candidate.Revision == revision + && string.Equals(candidate.Id, id.ToString(), StringComparison.OrdinalIgnoreCase)) + { + thumbnail = parts[2] == "thumbnail"; + if (thumbnail && string.IsNullOrEmpty(candidate.ThumbnailKey)) return false; + entry = candidate; + return true; + } + } + return false; + } + + private static void EnsureRefresh(bool loadCacheFirst) + { + lock (Gate) + { + if (_catalogUri == null || _refreshing + || (!loadCacheFirst && DateTime.UtcNow - _lastRefreshUtc < RefreshInterval)) return; + _refreshing = true; + } + + ThreadPool.QueueUserWorkItem(delegate + { + if (loadCacheFirst) TryLoadCachedCatalog(); + try + { + var bytes = Download(_catalogUri, CommunityCatalog.MaxDocumentBytes); + CommunityCatalog parsed; + string warning; + if (!CommunityCatalog.TryParse(bytes, out parsed, out warning)) + throw new InvalidDataException(warning ?? "The catalogue is invalid."); + lock (Gate) _catalog = parsed; + WriteAtomic(_catalogCacheFile, bytes); + if (!string.IsNullOrEmpty(warning)) + Plugin.Log.LogWarning("Community catalogue: " + warning); + Plugin.Log.LogInfo("Community catalogue loaded " + parsed.Entries.Count + " map(s)." ); + } + catch (Exception ex) + { + Plugin.Log.LogWarning("Could not refresh the community catalogue; using the " + + "local library/cache: " + ex.Message); + } + finally + { + lock (Gate) + { + _lastRefreshUtc = DateTime.UtcNow; + _refreshing = false; + } + } + }); + } + + private static void TryLoadCachedCatalog() + { + try + { + var bytes = ReadCacheFile(_catalogCacheFile, CommunityCatalog.MaxDocumentBytes); + CommunityCatalog parsed; + string warning; + if (CommunityCatalog.TryParse(bytes, out parsed, out warning)) + { + lock (Gate) _catalog = parsed; + Plugin.Log.LogInfo("Loaded " + parsed.Entries.Count + " cached community map(s)." ); + } + } + catch (Exception ex) + { + Plugin.Log.LogWarning("Ignoring the cached community catalogue: " + ex.Message); + } + } + + private static byte[] Download(Uri uri, int maximumBytes) + { + var stopwatch = Stopwatch.StartNew(); + var current = uri; + for (var redirects = 0; redirects <= 4; redirects++) + { + var remaining = RequestTimeoutMs - (int)stopwatch.ElapsedMilliseconds; + if (remaining <= 0) throw new TimeoutException("The HTTPS request exceeded 10 seconds."); + var request = (HttpWebRequest)WebRequest.Create(current); + request.Method = "GET"; + request.AllowAutoRedirect = false; + request.AutomaticDecompression = DecompressionMethods.None; + request.Timeout = remaining; + request.ReadWriteTimeout = remaining; + request.UserAgent = "SS2Revive/" + Plugin.PluginVersion; + + using (var deadline = new Timer(delegate { try { request.Abort(); } catch { } }, null, + remaining, Timeout.Infinite)) + using (var response = (HttpWebResponse)request.GetResponse()) + { + var status = (int)response.StatusCode; + if (status == 301 || status == 302 || status == 303 || status == 307 || status == 308) + { + Uri next; + if (redirects == 4 || !Uri.TryCreate(current, response.Headers["Location"], out next) + || !IsAllowedRedirect(current, next)) + throw new WebException("HTTPS returned an unsafe redirect."); + current = next; + continue; + } + if (response.StatusCode != HttpStatusCode.OK) + throw new WebException("HTTPS returned " + status + "."); + if (response.ContentLength > maximumBytes) + throw new InvalidDataException("The response exceeds its byte limit."); + + using (var input = response.GetResponseStream()) + using (var output = new MemoryStream()) + { + var buffer = new byte[32 * 1024]; + while (true) + { + if (stopwatch.ElapsedMilliseconds > RequestTimeoutMs) + throw new TimeoutException("The HTTPS request exceeded 10 seconds."); + var read = input.Read(buffer, 0, buffer.Length); + if (read <= 0) break; + if (output.Length + read > maximumBytes) + throw new InvalidDataException("The response exceeds its byte limit."); + output.Write(buffer, 0, read); + } + return output.ToArray(); + } + } + } + throw new WebException("HTTPS returned too many redirects."); + } + + /// + /// Keep redirects same-origin. GitHub Releases is the one explicit exception: its stable + /// github.com asset URLs redirect to GitHub's own immutable content CDN. + /// + private static bool IsAllowedRedirect(Uri from, Uri to) + { + if (to == null || !string.Equals(to.Scheme, Uri.UriSchemeHttps, + StringComparison.OrdinalIgnoreCase)) return false; + if (string.Equals(from.Host, to.Host, StringComparison.OrdinalIgnoreCase) + && from.Port == to.Port) return true; + return string.Equals(from.Host, "github.com", StringComparison.OrdinalIgnoreCase) + && (string.Equals(to.Host, "githubusercontent.com", StringComparison.OrdinalIgnoreCase) + || to.Host.EndsWith(".githubusercontent.com", StringComparison.OrdinalIgnoreCase)); + } + + private static byte[] ReadCacheFile(string path, int maximumBytes) + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null; + var info = new FileInfo(path); + if (info.Length <= 0 || info.Length > maximumBytes) return null; + return File.ReadAllBytes(path); + } + + private static bool IsExpected(byte[] bytes, long size, string sha) => + bytes != null && bytes.LongLength == size + && string.Equals(Sha256Hex(bytes), sha, StringComparison.OrdinalIgnoreCase); + + private static string Sha256Hex(byte[] bytes) + { + if (bytes == null) return string.Empty; + using (var sha = SHA256.Create()) + { + var hash = sha.ComputeHash(bytes); + var result = new StringBuilder(hash.Length * 2); + for (var i = 0; i < hash.Length; i++) result.Append(hash[i].ToString("x2")); + return result.ToString(); + } + } + + private static void WriteAtomic(string path, byte[] bytes) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)); + var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + File.WriteAllBytes(temporary, bytes); + if (File.Exists(path)) File.Replace(temporary, path, null); + else File.Move(temporary, path); + } + finally + { + try { if (File.Exists(temporary)) File.Delete(temporary); } + catch { } + } + } + + private static void EvictObjectCache() + { + try + { + var files = new List(new DirectoryInfo(_objectCacheDirectory).GetFiles()); + files.Sort((a, b) => a.LastWriteTimeUtc.CompareTo(b.LastWriteTimeUtc)); + long total = 0; + for (var i = 0; i < files.Count; i++) total += files[i].Length; + while (files.Count > MaxCacheFiles || total > MaxCacheBytes) + { + var oldest = files[0]; + files.RemoveAt(0); + total -= oldest.Length; + oldest.Delete(); + } + } + catch (Exception ex) + { + Plugin.Log.LogWarning("Could not trim the community-map cache: " + ex.Message); + } + } + } +} diff --git a/src/SS2Revive/LevelFormatGuard.cs b/src/SS2Revive/LevelFormatGuard.cs new file mode 100644 index 0000000..4b68de8 --- /dev/null +++ b/src/SS2Revive/LevelFormatGuard.cs @@ -0,0 +1,606 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using Data; +using HarmonyLib; +using Services; +using Services.Network; +using UnityEngine; + +namespace SS2Revive +{ + /// + /// Bounds on the level reader, so that a level file which lies about its own size cannot take + /// the game down. + /// + /// The level format has three places where a number read straight out of the file decides the + /// size of an allocation, none of them checked. In format 29 - the one the game writes today - + /// they are: + /// + /// Serializer.ReadInt(readStream, out var value3, int.MinValue, int.MaxValue); + /// byte[] array = new byte[value3]; // any size, including negative + /// ... + /// deflateStream.CopyTo(memoryStream); // no cap on what comes out + /// ... + /// Serializer.ReadVector3Int(readStream, out var value); + /// levelData._voxelStates = new VoxelState[value.x, value.y, value.z]; + /// + /// The last one is the worst, and it is a regression rather than an oversight that was always + /// there: formats 1 to 9 read their dimensions as 16-bit values and refused any level whose + /// volume passed 262,144 - if (value * value2 * value3 > 262144) throw new + /// SerializeException(); - and the check was dropped when the format moved to a full 32-bit + /// Vector3Int. Every format from 10 up allocates two three-dimensional arrays from + /// numbers an attacker chooses. + /// + /// This matters more here than it would in a single-player game. The party host's level is sent + /// to every peer and read by all of them, so one crafted level does not cost one player - it + /// costs the lobby. And it is reachable today through any route that moves a level between + /// machines, which is exactly what this build adds one of. + /// + /// The limits are not guesses. LevelDataService.WriteLevelData serialises into a fixed + /// new byte[4096000], so the game is incapable of producing a level body larger than + /// that; and a voxel costs one bit for its state plus a 32-bit word for its textures, so a body + /// that size cannot describe more than about 993,000 voxels. Both bounds below sit just above + /// what the game's own writer can emit, which means no level this game ever wrote can trip + /// them. + /// + /// Nothing here throws. A refused level loads as an empty room and says so in the log, because + /// on the receiving end of a party the alternative to a wrong level is not a right one, it is + /// everybody dropping out. + /// + internal static class LevelFormatGuard + { + /// + /// The working buffer LevelDataService.WriteLevelData serialises into. A level body + /// bigger than this did not come from this game. + /// + private const int MaxBodyBytes = 4096000; + + /// Deflate can expand incompressible input slightly; this is room for that. + private const int MaxCompressedBytes = 8 * 1024 * 1024; + + /// + /// 2^20, against the ~993,000 voxels a maximum-sized body could actually describe. For + /// scale, every format before the check was dropped refused anything past 262,144. + /// + private const long MaxVoxelVolume = 1024 * 1024; + private const int MaxVoxelAxis = 256; + private const int MaxSummaryBytes = 1024 * 1024; + private const int MaxWarningDefinitions = 4096; + private const int MaxWarningsPerDefinition = 256; + private const int MaxPropDefinitions = 4096; + private const int MaxPropInstances = 20000; + private const int MaxCircuitLinks = 20000; + + private static MethodInfo _readBody; + private static readonly HashSet TrustedBundledLevelHashes = + new HashSet(StringComparer.Ordinal); + [ThreadStatic] private static bool _readingBody; + [ThreadStatic] private static bool _readingSummary; + + internal static void Apply(Harmony harmony) + { + LoadTrustedBundledLevelHashes(); + + PatchSet.Try("Serializer.ReadVector3Int -> bound level dimensions", () => + { + var target = PatchSet.Method(typeof(Serializer), "ReadVector3Int"); + harmony.Patch(target, null, new HarmonyMethod( + AccessTools.Method(typeof(LevelFormatGuard), nameof(ReadVector3Int_Postfix)))); + }); + + PatchSet.Try("LevelFormat.ReadLevelData_Format29 -> bound the compressed body", () => + { + _readBody = AccessTools.Method(typeof(LevelFormat), "ReadLevelData", + new[] { typeof(ReadStream), typeof(LevelData).MakeByRefType() }); + + if (_readBody == null) + throw new MissingMethodException("LevelFormat.ReadLevelData(ReadStream, ref LevelData)"); + + var target = PatchSet.Method(typeof(LevelFormat), "ReadLevelData_Format29"); + harmony.Patch(target, new HarmonyMethod( + AccessTools.Method(typeof(LevelFormatGuard), nameof(Format29_Prefix)))); + }); + + PatchSet.Try("LevelDataService.ReadLevelData -> current or trusted bundled formats", () => + { + var target = PatchSet.Method(typeof(LevelDataService), "ReadLevelData", + new[] { typeof(byte[]) }); + harmony.Patch(target, new HarmonyMethod( + AccessTools.Method(typeof(LevelFormatGuard), nameof(ReadLevelData_Prefix)))); + }); + + PatchSet.Try("format 29 dictionaries -> bound allocation counts", () => + { + PatchDictionaryConstructor(harmony, + typeof(Dictionary)); + PatchDictionaryConstructor(harmony, typeof(Dictionary)); + PatchDictionaryConstructor(harmony, typeof(Dictionary)); + }); + + PatchSet.Try("Serializer.ReadInt -> bound summary allocations", () => + { + var target = PatchSet.Method(typeof(Serializer), "ReadInt", + new[] { typeof(ReadStream), typeof(int).MakeByRefType(), typeof(int), typeof(int) }); + harmony.Patch(target, null, new HarmonyMethod( + AccessTools.Method(typeof(LevelFormatGuard), nameof(SummaryReadInt_Postfix)))); + }); + + PatchSet.Try("MessageSerializer.ReadLevelDataMessage -> preflight compressed level", () => + { + var target = PatchSet.Method(typeof(MessageSerializer), "ReadLevelDataMessage"); + harmony.Patch(target, + new HarmonyMethod(AccessTools.Method(typeof(LevelFormatGuard), + nameof(NetworkLevelMessage_Prefix))), + new HarmonyMethod(AccessTools.Method(typeof(LevelFormatGuard), + nameof(NetworkLevelMessage_Postfix)))); + }); + + PatchSet.Try("LevelSummaryDataService.ReadLevelSummaryData -> bound collection counts", () => + { + var target = PatchSet.Method(typeof(LevelSummaryDataService), "ReadLevelSummaryData", + new[] { typeof(byte[]) }); + harmony.Patch(target, + new HarmonyMethod(AccessTools.Method(typeof(LevelFormatGuard), + nameof(SummaryRead_Prefix))), + new HarmonyMethod(AccessTools.Method(typeof(LevelFormatGuard), + nameof(SummaryRead_Postfix)))); + }); + + PatchSet.Try("LevelSummaryDataService.ReadLevelSummaryDataImage -> validate image envelope", () => + { + var target = PatchSet.Method(typeof(LevelSummaryDataService), + "ReadLevelSummaryDataImage", new[] { typeof(byte[]) }); + harmony.Patch(target, new HarmonyMethod(AccessTools.Method(typeof(LevelFormatGuard), + nameof(ImageRead_Prefix)))); + }); + } + + // -------------------------------------------------------------- dimensions + + /// + /// Every call to ReadVector3Int in the game is a level-size read - thirteen of them, + /// one per format version, and nothing else uses it. So a blanket bound here is as narrow + /// as a bound at each call site would be, and does not depend on which format reader a file + /// asked for. + /// + /// Refused dimensions become zero rather than being clamped to the limit. Clamping would + /// hand the loops below a size the file's own bit stream does not match, and they would + /// read a million voxels' worth of whatever came next; zero skips them, and the reads after + /// the grid carry on from the right place because the dimensions themselves were already + /// consumed. + /// + private static void ReadVector3Int_Postfix(ref Vector3Int value) + { + var x = value.x; + var y = value.y; + var z = value.z; + + if (x > 0 && y > 0 && z > 0 + && x <= MaxVoxelAxis && y <= MaxVoxelAxis && z <= MaxVoxelAxis + && x <= MaxVoxelVolume / y / z) + { + return; + } + + Plugin.Log.LogError("Refusing a level that says it is " + x + "x" + y + "x" + z + + " voxels. The largest this game can write is under " + + MaxVoxelVolume + ", so this file is corrupt or was built to " + + "crash whoever opens it. Loading it as an empty room instead."); + + // Continuing after changing the dimensions would leave the original voxel bits unread + // and make every later field start at the wrong offset. Reject the whole level. + throw new SerializeException(); + } + + private static bool ReadLevelData_Prefix(byte[] data, ref LevelData __result) + { + var validEnvelope = data != null && data.Length >= 10 && data.Length <= MaxBodyBytes + && data[0] == 83 && data[1] == 117 + && data[2] == 114 && data[3] == 103 + && data[4] == 101 && data[5] == 111 + && data[6] == 110 && data[7] == 115; + if (validEnvelope) + { + var version = data[8] | (data[9] << 8); + if (version == 29) return true; + + // Build 1.3.7 itself ships most of its active lobby/campaign catalogue as format + // 28. Those files are trusted by exact SHA-256, loaded from StreamingAssets/Levels + // at startup. Merely naming a custom file like a bundled map grants nothing; its + // bytes have to match the installed game object exactly. + if (version > 0 && version < 29 && version != 25 + && IsTrustedBundledLevel(data)) + { + return true; + } + } + + Plugin.Log.LogError("Refusing level data that is oversized, malformed, or uses an old " + + "format without matching a level bundled with this game install. " + + "Custom and shared content is restricted to current format 29."); + __result = new LevelData(); + return false; + } + + private static void LoadTrustedBundledLevelHashes() + { + TrustedBundledLevelHashes.Clear(); + try + { + var root = Path.Combine(Application.streamingAssetsPath, "Levels"); + if (!Directory.Exists(root)) + { + Plugin.Log.LogWarning("Could not find the bundled level directory at " + root + + "; older bundled maps will remain restricted."); + return; + } + + var files = Directory.GetFiles(root, "*.lvl", SearchOption.AllDirectories); + for (var i = 0; i < files.Length; i++) + { + try + { + var info = new FileInfo(files[i]); + if (info.Length < 10 || info.Length > MaxBodyBytes) continue; + using (var input = new FileStream(files[i], FileMode.Open, FileAccess.Read, + FileShare.Read)) + using (var sha = SHA256.Create()) + { + TrustedBundledLevelHashes.Add(Hex(sha.ComputeHash(input))); + } + } + catch (Exception ex) + { + Plugin.Log.LogWarning("Could not trust bundled level " + files[i] + ": " + + ex.Message); + } + } + + Plugin.Log.LogInfo("Trusted " + TrustedBundledLevelHashes.Count + + " exact bundled level file(s); custom maps still require " + + "format 29."); + } + catch (Exception ex) + { + Plugin.Log.LogWarning("Could not catalogue the game's bundled levels: " + ex.Message); + } + } + + private static bool IsTrustedBundledLevel(byte[] data) + { + if (data == null || TrustedBundledLevelHashes.Count == 0) return false; + using (var sha = SHA256.Create()) + { + return TrustedBundledLevelHashes.Contains(Hex(sha.ComputeHash(data))); + } + } + + private static string Hex(byte[] bytes) + { + var text = new StringBuilder(bytes.Length * 2); + for (var i = 0; i < bytes.Length; i++) text.Append(bytes[i].ToString("x2")); + return text.ToString(); + } + + private static void PatchDictionaryConstructor(Harmony harmony, Type dictionaryType) + { + var target = AccessTools.Constructor(dictionaryType, new[] { typeof(int) }); + if (target == null) throw new MissingMethodException(dictionaryType.FullName, ".ctor(int)"); + harmony.Patch(target, new HarmonyMethod( + AccessTools.Method(typeof(LevelFormatGuard), nameof(BodyDictionary_Prefix)))); + } + + private static void BodyDictionary_Prefix(int capacity, MethodBase __originalMethod) + { + if (!_readingBody) return; + + var dictionaryType = __originalMethod.DeclaringType; + var valueType = dictionaryType == null ? null : dictionaryType.GetGenericArguments()[1]; + var maximum = valueType == typeof(Bossa.Framework.Utils.Guid) + ? MaxPropDefinitions + : valueType == typeof(PropInstanceData) + ? MaxPropInstances + : MaxCircuitLinks; + if (capacity >= 0 && capacity <= maximum) return; + + Plugin.Log.LogError("Refusing a level body that requests a " + capacity + + "-entry " + valueType.Name + " dictionary (maximum " + + maximum + ")."); + throw new SerializeException(); + } + + private static void SummaryReadInt_Postfix(ref int value) + { + if (!_readingSummary || (value >= -MaxSummaryBytes && value <= MaxSummaryBytes)) return; + + Plugin.Log.LogError("Refusing level-summary data containing an integer outside its " + + MaxSummaryBytes + " byte envelope: " + value + "."); + _readingSummary = false; + throw new SerializeException(); + } + + private static bool SummaryRead_Prefix(byte[] data, ref LevelSummaryData __result) + { + _readingSummary = false; + if (data == null || data.Length == 0 || data.Length > MaxSummaryBytes) + { + Plugin.Log.LogError("Refusing empty or oversized level-summary data."); + __result = null; + return false; + } + + _readingSummary = true; + return true; + } + + private static void SummaryRead_Postfix(ref LevelSummaryData __result) + { + _readingSummary = false; + if (__result == null) return; + + var image = __result.legacyLevelImage; + var bytes = image.data; + if (bytes == null || bytes.Length == 0) return; + if (SS2ReviveData.LevelBundle.IsSafeRawGameImage( + (uint)Math.Max(0, image.width), (uint)Math.Max(0, image.height), + (uint)image.format, (uint)bytes.Length)) return; + + Plugin.Log.LogError("Dropping an invalid embedded level-summary image."); + __result.legacyLevelImage = new LevelSummaryImageData(null, 0, 0, TextureFormat.Alpha8); + } + + private static bool ImageRead_Prefix(byte[] data, ref LevelSummaryImageData __result) + { + if (SS2ReviveData.LevelBundle.IsSafeGameImage(data)) return true; + + Plugin.Log.LogError("Refusing an invalid or oversized level image envelope."); + __result = new LevelSummaryImageData(null, 0, 0, TextureFormat.Alpha8); + return false; + } + + private static void NetworkLevelMessage_Prefix(MessageSerializer __instance, ref int __state) + { + __state = -1; + try + { + var lengthField = AccessTools.Field(typeof(MessageSerializer), "_readBufferLength"); + var bufferField = AccessTools.Field(typeof(MessageSerializer), "_readBuffer"); + var length = (int)lengthField.GetValue(__instance); + var buffer = bufferField.GetValue(__instance) as byte[]; + + if (ValidateNetworkLevelMessage(buffer, length)) return; + + __state = length; + lengthField.SetValue(__instance, 0); + Plugin.Log.LogError("Refusing a malformed or oversized multiplayer level message."); + } + catch (Exception ex) + { + Plugin.Log.LogError("Could not preflight a multiplayer level message: " + ex.Message); + var lengthField = AccessTools.Field(typeof(MessageSerializer), "_readBufferLength"); + __state = (int)lengthField.GetValue(__instance); + lengthField.SetValue(__instance, 0); + } + } + + private static void NetworkLevelMessage_Postfix(MessageSerializer __instance, int __state) + { + if (__state >= 0) + AccessTools.Field(typeof(MessageSerializer), "_readBufferLength") + .SetValue(__instance, __state); + } + + private static bool ValidateNetworkLevelMessage(byte[] buffer, int length) + { + if (buffer == null || length <= 0 || length > buffer.Length + || length > MaxCompressedBytes + MaxSummaryBytes) return false; + + var stream = new ReadStream(); + stream.Start(new uint[(length + 3) / 4], buffer, length); + + byte messageType; + int ignored; + bool optional; + Bossa.Framework.Utils.Guid ignoredGuid; + + Serializer.ReadBits(stream, out messageType, 8); + if (messageType != 21) return false; + Serializer.ReadInt(stream, out ignored, 0, int.MaxValue); + Serializer.ReadInt(stream, out ignored, 0, int.MaxValue); + Serializer.ReadBool(stream, out optional); + if (optional) Serializer.ReadInt(stream, out ignored, 0, int.MaxValue); + Serializer.ReadInt(stream, out ignored, 0, int.MaxValue); + Serializer.ReadInt(stream, out ignored, 0, int.MaxValue); + Serializer.ReadBool(stream, out optional); + Serializer.ReadBool(stream, out optional); + Serializer.ReadGuid(stream, out ignoredGuid); + + int summaryLength; + Serializer.ReadInt(stream, out summaryLength, 0, int.MaxValue); + if (summaryLength < 0 || summaryLength > MaxSummaryBytes + || !stream.SerializeAlign()) return false; + stream.SkipBytes(summaryLength); + + Serializer.ReadInt(stream, out ignored, 0, int.MaxValue); // level mode + + int definitions; + Serializer.ReadInt(stream, out definitions, 0, int.MaxValue); + if (definitions < 0 || definitions > MaxWarningDefinitions) return false; + var definitionIds = new HashSet(); + for (var i = 0; i < definitions; i++) + { + Serializer.ReadGuid(stream, out ignoredGuid); + if (!definitionIds.Add(ignoredGuid)) return false; + int warnings; + Serializer.ReadInt(stream, out warnings, 0, int.MaxValue); + if (warnings < 0 || warnings > MaxWarningsPerDefinition) return false; + for (var w = 0; w < warnings; w++) + Serializer.ReadInt(stream, out ignored, 0, 255); + } + + int compressedLength; + Serializer.ReadInt(stream, out compressedLength, 0, int.MaxValue); + if (compressedLength <= 0 || compressedLength > MaxCompressedBytes + || !stream.SerializeAlign()) return false; + + var compressed = new byte[compressedLength]; + Serializer.ReadBytes(stream, compressed, compressedLength); + + byte[] levelBytes; + if (!TryInflateGZip(compressed, out levelBytes)) return false; + return levelBytes.Length >= 10 && levelBytes.Length <= MaxBodyBytes + && levelBytes[0] == 83 && levelBytes[1] == 117 + && levelBytes[2] == 114 && levelBytes[3] == 103 + && levelBytes[4] == 101 && levelBytes[5] == 111 + && levelBytes[6] == 110 && levelBytes[7] == 115 + && (levelBytes[8] | (levelBytes[9] << 8)) == 29; + } + + private static bool TryInflateGZip(byte[] compressed, out byte[] outputBytes) + { + outputBytes = null; + try + { + using (var source = new MemoryStream(compressed)) + using (var gzip = new GZipStream(source, CompressionMode.Decompress)) + using (var output = new MemoryStream()) + { + var chunk = new byte[64 * 1024]; + while (true) + { + var read = gzip.Read(chunk, 0, chunk.Length); + if (read <= 0) break; + if (output.Length + read > MaxBodyBytes) return false; + output.Write(chunk, 0, read); + } + outputBytes = output.ToArray(); + return true; + } + } + catch (Exception) + { + return false; + } + } + + // ------------------------------------------------------------- compression + + /// + /// The original, with the two unbounded steps bounded. Everything else about it is + /// unchanged, including the bit widths, so a file this accepts deserialises identically. + /// + private static bool Format29_Prefix(LevelData levelData, ReadStream readStream, + ref LevelData __result) + { + __result = levelData; + + try + { + string clientLevelId; + Serializer.ReadString(readStream, out clientLevelId); + levelData.clientLevelId = new Bossa.Framework.Utils.Guid(clientLevelId); + + bool isCompressed; + Serializer.ReadBool(readStream, out isCompressed); + + if (!isCompressed) + { + ReadBody(readStream, ref levelData); + __result = levelData; + return false; + } + + int compressedLength; + Serializer.ReadInt(readStream, out compressedLength, int.MinValue, int.MaxValue); + + if (compressedLength < 0 || compressedLength > MaxCompressedBytes) + { + Plugin.Log.LogError("Refusing a level whose compressed body claims to be " + + compressedLength + " bytes. Loading it as an empty room."); + return false; + } + + var compressed = new byte[compressedLength]; + Serializer.ReadBytes(readStream, compressed, compressedLength); + + byte[] body; + if (!TryInflate(compressed, out body)) + return false; + + var inner = new ReadStream(); + inner.Start(new uint[(body.Length + 3) / 4], body, body.Length); + ReadBody(inner, ref levelData); + + __result = levelData; + return false; + } + catch (Exception ex) + { + // The original would have thrown out of here too, but out of a reader with no + // bounds in it. Whatever is left of levelData is returned rather than propagating, + // for the same reason the dimension check does not throw. + Plugin.Log.LogError("Reading a level failed: " + ex); + return false; + } + } + + /// + /// Decompresses with a ceiling, which CopyTo does not have. A few hundred bytes of + /// deflate expand to gigabytes if that is what they were built to do, and the original both + /// holds the result in a MemoryStream and then copies it again with ToArray - so the + /// peak is twice whatever the file asks for. + /// + private static bool TryInflate(byte[] compressed, out byte[] body) + { + body = null; + + using (var source = new MemoryStream(compressed)) + using (var deflate = new DeflateStream(source, CompressionMode.Decompress)) + using (var output = new MemoryStream()) + { + var chunk = new byte[64 * 1024]; + var total = 0; + + while (true) + { + var read = deflate.Read(chunk, 0, chunk.Length); + if (read <= 0) break; + + total += read; + if (total > MaxBodyBytes) + { + Plugin.Log.LogError("Refusing a level that expands past " + MaxBodyBytes + + " bytes; the game's own writer cannot produce one " + + "that large. Loading it as an empty room."); + return false; + } + + output.Write(chunk, 0, read); + } + + body = output.ToArray(); + return true; + } + } + + private static void ReadBody(ReadStream stream, ref LevelData levelData) + { + _readingBody = true; + try + { + var args = new object[] { stream, levelData }; + _readBody.Invoke(null, args); + levelData = (LevelData)args[1]; + } + finally + { + _readingBody = false; + } + } + } +} diff --git a/src/SS2Revive/LevelSharing.cs b/src/SS2Revive/LevelSharing.cs new file mode 100644 index 0000000..f1a4ff4 --- /dev/null +++ b/src/SS2Revive/LevelSharing.cs @@ -0,0 +1,446 @@ +using System; +using System.Collections.Generic; +using System.IO; +using SS2ReviveData; + +namespace SS2Revive +{ + /// + /// Levels moving between machines as files, because there is no service left to move them + /// through. + /// + /// The shape of this is set by what the game already does rather than by what would be tidy. + /// Surgeon Simulator 2 ships a share window that shows a 22-character code and copies it to the + /// clipboard, and a search box that turns a code of exactly that length back into a level-id + /// lookup. Both still work; the only thing missing was ever a way for the bytes to arrive. So a + /// level is exported to one file, that file is sent however people already talk to each other, + /// and the code stays what it always was - the name of the level, not the level itself. + /// + /// The code cannot carry the level, and it is worth being plain about why. A level blob is + /// kilobytes at the small end and megabytes at the large; a chat message is two thousand + /// characters. Even a nearly empty level does not fit, so any design where the code is the + /// whole payload was never available. + /// + /// What arrives is somebody else's level and stays that way: it comes in published, credited to + /// whoever built it, under the id it was exported with. That last part is what makes the code + /// worth anything - the same level answers to the same code on every machine that has it, which + /// is the property a shared index would otherwise have had to provide. + /// + internal static class LevelSharing + { + private const int MaxFilesPerPass = 8; + internal static string ExportDirectory { get; private set; } + internal static string ImportDirectory { get; private set; } + + /// + /// Where a bundle goes once it has been taken in. Moving it is what stops the folder being + /// imported twice - the Create screen scans it every time it opens. A repeated current + /// revision is harmless, but moving handled files keeps the inbox and its feedback clear. + /// + internal static string ImportedDirectory { get; private set; } + internal static string RejectedDirectory { get; private set; } + + /// + /// Files taken in this session, as a backstop for the move above failing - a bundle still + /// open in the browser that downloaded it, say. Without it a failed move would import the + /// same file again on the next scan. + /// + private static readonly HashSet Handled = + new HashSet(StringComparer.OrdinalIgnoreCase); + + internal static bool Available => ExportDirectory != null && UgcBackend.Store != null; + + internal static void Initialise(string saveDirectory) + { + try + { + var root = SaveLocation.ResolveDirectory(saveDirectory); + + ExportDirectory = Path.Combine(root, "export"); + ImportDirectory = Path.Combine(root, "import"); + ImportedDirectory = Path.Combine(ImportDirectory, "imported"); + RejectedDirectory = Path.Combine(ImportDirectory, "rejected"); + + // Created now rather than on first use, so both folders are there to be found by + // somebody who has been told to put a file in one of them. + Directory.CreateDirectory(ExportDirectory); + Directory.CreateDirectory(ImportDirectory); + Directory.CreateDirectory(ImportedDirectory); + Directory.CreateDirectory(RejectedDirectory); + + Plugin.Log.LogInfo("Level sharing ready. Export: " + ExportDirectory + + " | Import: " + ImportDirectory); + } + catch (Exception ex) + { + ExportDirectory = null; + ImportDirectory = null; + Plugin.Log.LogError("Could not prepare the level sharing folders; export and import " + + "are off for this session. " + ex); + } + } + + // ------------------------------------------------------------------ export + + /// + /// Writes one level to export\ and answers with the share code for it. + /// + /// Overwriting a file of the same name is deliberate. The name carries the level's id, so + /// the only file it can collide with is an older export of the same level, and keeping that + /// one would leave the player with two files and no way to tell which is current. + /// + internal static bool Export(string serverLevelId, out string code, out string message) + { + code = string.Empty; + message = null; + + var store = UgcBackend.Store; + if (store == null || ExportDirectory == null) + { + message = "The level library is not open, so nothing can be exported."; + return false; + } + + var level = store.Get(serverLevelId); + if (level == null) + { + message = "That level is not in this machine's library."; + return false; + } + + string error; + var bundle = LevelBundle.FromLevel(level, store.ReadKey, out error); + if (bundle == null) + { + message = error ?? "The level could not be read."; + return false; + } + + bundle.ReviveVersion = Plugin.PluginVersion; + + var fileName = bundle.SuggestedFileName(); + var path = Path.Combine(ExportDirectory, fileName); + + try + { + AtomicFile.WriteAllBytes(path, bundle.Pack()); + RemoveOlderExports(bundle.Code, path); + } + catch (Exception ex) + { + Plugin.Log.LogError("Exporting '" + level.Title + "' failed: " + ex); + message = "The file could not be written: " + ex.Message; + return false; + } + + code = bundle.Code; + + Plugin.Log.LogInfo("Exported '" + level.Title + "' (" + level.Id + ") to " + path + + ". Share code " + code + "."); + return true; + } + + private static void RemoveOlderExports(string code, string keepPath) + { + if (string.IsNullOrEmpty(code) || ExportDirectory == null) return; + + try + { + var files = Directory.GetFiles(ExportDirectory, "*" + LevelBundle.Extension, + SearchOption.TopDirectoryOnly); + var marker = "[" + code + "]"; + for (var i = 0; i < files.Length; i++) + { + if (string.Equals(files[i], keepPath, StringComparison.OrdinalIgnoreCase)) continue; + if (Path.GetFileName(files[i]).IndexOf(marker, + StringComparison.OrdinalIgnoreCase) < 0) continue; + File.Delete(files[i]); + } + } + catch (Exception ex) + { + // The new export is already durable. A stale older filename is untidy, not a + // reason to report that the export itself failed. + Plugin.Log.LogWarning("Could not remove an older export for " + code + ": " + + ex.Message); + } + } + + // ------------------------------------------------------------------ import + + /// What one pass over the import folder did. + internal struct ImportResult + { + internal int Imported; + internal int Updated; + internal int Current; + internal int Older; + internal int Rejected; + internal int Pending; + internal string FirstError; + + internal int Seen => Imported + Updated + Current + Older + Rejected; + internal int Added => Imported + Updated; + } + + /// + /// Reads every bundle sitting in import\ into the library, and moves each one into + /// import\imported\ as it goes. + /// + /// The install itself is idempotent by id and revision; the move also keeps an already + /// handled file from producing the same "current" message on every screen visit. + /// + internal static ImportResult ImportAll() + { + var result = new ImportResult(); + + var store = UgcBackend.Store; + if (store == null || ImportDirectory == null) return result; + + string[] files; + try + { + files = Directory.GetFiles(ImportDirectory, "*" + LevelBundle.Extension, + SearchOption.TopDirectoryOnly); + } + catch (Exception ex) + { + Plugin.Log.LogWarning("Could not read the import folder: " + ex.Message); + return result; + } + + var attempted = 0; + for (var i = 0; i < files.Length; i++) + { + if (Handled.Contains(files[i])) continue; + if (attempted >= MaxFilesPerPass) + { + result.Pending++; + continue; + } + attempted++; + ImportOne(store, files[i], ref result); + } + + return result; + } + + private static void ImportOne(UgcStore store, string path, ref ImportResult result) + { + var name = Path.GetFileName(path); + + byte[] bytes; + try + { + var info = new FileInfo(path); + // A browser or Explorer copy exposes its destination before the last byte has + // arrived. Leave recently-written files alone so the next deliberate scan sees a + // complete bundle instead of quarantining a transient truncation. + if (DateTime.UtcNow - info.LastWriteTimeUtc < TimeSpan.FromSeconds(2)) return; + + if (info.Length <= 0 || info.Length > LevelBundle.MaxBundleBytes) + { + Reject(ref result, path, "its size is outside the 1-" + + (LevelBundle.MaxBundleBytes / (1024 * 1024)) + " MB limit"); + return; + } + + // Open once and keep the handle while checking and reading. This prevents a file + // from being replaced or grown between FileInfo.Length and ReadAllBytes, which + // would otherwise allocate before the bundle reader gets a chance to reject it. + using (var input = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + if (input.Length <= 0 || input.Length > LevelBundle.MaxBundleBytes) + { + Reject(ref result, path, "its size is outside the 1-" + + (LevelBundle.MaxBundleBytes / (1024 * 1024)) + " MB limit"); + return; + } + + bytes = new byte[(int)input.Length]; + var offset = 0; + while (offset < bytes.Length) + { + var read = input.Read(bytes, offset, bytes.Length - offset); + if (read <= 0) throw new EndOfStreamException("The bundle changed while it was read."); + offset += read; + } + if (input.ReadByte() != -1) + throw new InvalidDataException("The bundle grew while it was read."); + } + } + catch (Exception ex) + { + // Sharing clients and browsers can hold the destination open briefly after its + // final write. Treat I/O failures as transient and retry on the next scan. + Plugin.Log.LogWarning("Could not read " + name + " yet: " + ex.Message); + return; + } + + string error; + var bundle = LevelBundle.Unpack(bytes, out error); + if (bundle == null) + { + Reject(ref result, path, error); + return; + } + + var prototype = new UgcLevelRecord + { + Id = bundle.Id, + Title = bundle.Title, + Description = bundle.Description, + CreatorIds = new List(bundle.CreatorIds), + Tags = new List(bundle.Tags), + CreatedAtMs = bundle.CreatedAtMs, + Configurations = bundle.Configurations, + Validations = bundle.Validations, + }; + + UgcInstallOutcome outcome; + var adopted = store.Install(prototype, bundle.ClientVersion, bundle.ContentVersion, + bundle.ExportedAtMs, bundle.Content, bundle.ContentImage, + bundle.Thumbnail, out outcome, out error); + + if (outcome == UgcInstallOutcome.Failed || outcome == UgcInstallOutcome.Conflict) + { + Reject(ref result, path, error); + return; + } + + switch (outcome) + { + case UgcInstallOutcome.Added: result.Imported++; break; + case UgcInstallOutcome.Updated: result.Updated++; break; + case UgcInstallOutcome.Current: result.Current++; break; + case UgcInstallOutcome.Older: result.Older++; break; + } + + Plugin.Log.LogInfo((outcome == UgcInstallOutcome.Updated ? "Updated '" + : outcome == UgcInstallOutcome.Current ? "Already current: '" + : outcome == UgcInstallOutcome.Older ? "Ignored older copy of '" + : "Installed '") + adopted.Title + "' (" + adopted.Id + ", revision " + + adopted.LatestContent().ContentVersion + ", code " + + LevelCode.FromLevelId(adopted.Id) + ") from " + name + "."); + + MoveAside(path); + } + + /// + /// Moves a bundle out of the way once it is in the library, so the next scan does not see + /// it again. A failure here is recorded in memory instead, which covers the session; the + /// worst a restart can then do is add one more copy, which is visible and deletable rather + /// than silent. + /// + private static void MoveAside(string path) + { + Handled.Add(path); + + if (ImportedDirectory == null) return; + + try + { + Directory.CreateDirectory(ImportedDirectory); + + var name = Path.GetFileNameWithoutExtension(path); + var extension = Path.GetExtension(path); + + var destination = Path.Combine(ImportedDirectory, name + extension); + for (var n = 2; File.Exists(destination) && n < 1000; n++) + destination = Path.Combine(ImportedDirectory, name + " (" + n + ")" + extension); + + File.Move(path, destination); + } + catch (Exception ex) + { + Plugin.Log.LogWarning("Imported " + Path.GetFileName(path) + " but could not move it " + + "into the imported folder, so please move or delete it " + + "yourself: " + ex.Message); + } + } + + private static void Reject(ref ImportResult result, string path, string reason) + { + result.Rejected++; + if (result.FirstError == null) result.FirstError = reason; + + Plugin.Log.LogWarning("Skipped " + Path.GetFileName(path) + ": " + + (reason ?? "it could not be read.")); + + MoveRejected(path); + } + + private static void MoveRejected(string path) + { + Handled.Add(path); + if (RejectedDirectory == null) return; + + try + { + Directory.CreateDirectory(RejectedDirectory); + var name = Path.GetFileNameWithoutExtension(path); + var extension = Path.GetExtension(path); + var destination = Path.Combine(RejectedDirectory, name + extension); + for (var n = 2; File.Exists(destination) && n < 1000; n++) + destination = Path.Combine(RejectedDirectory, name + " (" + n + ")" + extension); + File.Move(path, destination); + } + catch (Exception ex) + { + Plugin.Log.LogWarning("Could not move rejected " + Path.GetFileName(path) + + " into quarantine: " + ex.Message); + } + } + + /// + /// One line fit for an in-game prompt. Null when there is nothing worth interrupting the + /// player about, which is the usual case on a screen that scans the folder every time it + /// opens. + /// + internal static string Describe(ImportResult result, bool sayNothingHappened) + { + if (result.Added > 0) + { + var line = result.Imported > 0 && result.Updated > 0 + ? "Installed " + result.Imported + " and updated " + result.Updated + " levels" + : result.Updated > 0 + ? (result.Updated == 1 ? "Updated 1 level" : "Updated " + result.Updated + " levels") + : (result.Imported == 1 ? "Installed 1 level" : "Installed " + result.Imported + " levels"); + + line += ". Look under Discover."; + + if (result.Current > 0) line += " " + result.Current + " already current."; + if (result.Older > 0) line += " " + result.Older + " older version skipped."; + if (result.Pending > 0) line += " More files are waiting; press Import again."; + + return result.Rejected > 0 ? line + " " + result.Rejected + " skipped." : line; + } + + if (result.Current > 0 || result.Older > 0) + { + var line = result.Current > 0 + ? (result.Current == 1 ? "That level is already current." + : result.Current + " levels are already current.") + : string.Empty; + if (result.Older > 0) + line += (line.Length > 0 ? " " : string.Empty) + result.Older + + (result.Older == 1 ? " older version was skipped." + : " older versions were skipped."); + return line; + } + + if (result.Rejected > 0) + { + return result.Rejected == 1 + ? "Could not import that file: " + (result.FirstError ?? "it could not be read.") + : "Could not import " + result.Rejected + " files. See the log for why."; + } + + return sayNothingHappened + ? (result.Pending > 0 + ? "More files are waiting; press Import again." + : "Nothing to import. Put .ss2level files in the import folder and press this again.") + : null; + } + } +} diff --git a/src/SS2Revive/PatchSet.cs b/src/SS2Revive/PatchSet.cs index 95be7c1..b207668 100644 --- a/src/SS2Revive/PatchSet.cs +++ b/src/SS2Revive/PatchSet.cs @@ -34,6 +34,12 @@ internal static void ApplyAll(Harmony harmony) if (Plugin.DisableVoip.Value) ApplyVoipDisable(harmony); + // Independent of Creation Mode and of level sharing on purpose. Levels arrive from + // other machines through the party as well - the host's level is pushed to every peer - + // so the reader wants bounding whether or not this player ever imports a file. + if (Plugin.HardenLevelReader.Value) + LevelFormatGuard.Apply(harmony); + if (Plugin.NewsFeedEnabled.Value) ApplyNewsFeed(harmony); @@ -68,9 +74,24 @@ internal static void ApplyAll(Harmony harmony) { UgcBackend.Initialise(Plugin.SaveDirectory.Value); if (UgcBackend.Available) + { UgcPatches.Apply(harmony); + + // After the library, because export and import are both operations on it and + // there is nothing to share without one. + if (Plugin.LevelSharingEnabled.Value) + { + LevelSharing.Initialise(Plugin.SaveDirectory.Value); + if (LevelSharing.Available) + SharingPatches.Apply(harmony); + else + Report.Add("FAIL Level sharing -> the export and import folders could not be opened"); + } + } else + { Report.Add("FAIL Creation Mode -> the level library could not be opened"); + } } // After the level library, because that is where the levels worth queueing come from. diff --git a/src/SS2Revive/Plugin.cs b/src/SS2Revive/Plugin.cs index ca85e15..7b4a092 100644 --- a/src/SS2Revive/Plugin.cs +++ b/src/SS2Revive/Plugin.cs @@ -64,7 +64,10 @@ public sealed class Plugin : BaseUnityPlugin internal static ConfigEntry LocalParty; internal static ConfigEntry HttpFailFast; internal static ConfigEntry SkipMatchmaking; + internal static ConfigEntry HardenLevelReader; internal static ConfigEntry CreationMode; + internal static ConfigEntry LevelSharingEnabled; + internal static ConfigEntry CommunityCatalogUrl; internal static ConfigEntry FreeForAll; internal static ConfigEntry FreeForAllIncludeGameLevels; internal static ConfigEntry Backend; @@ -107,6 +110,14 @@ private void Awake() "Start levels with whoever is already in the party instead of holding the vactube " + "screen open for strangers. Bossa's matchmaking server is gone, so the wait can " + "only ever time out."); + HardenLevelReader = Config.Bind("Security", "HardenLevelReader", true, + "Put bounds on the level file reader. The format lets a file declare its own voxel " + + "dimensions and its own decompressed size with nothing checking either, so a " + + "level built to do so can ask for an allocation no machine can satisfy. That " + + "matters in a party, where the host's level is sent to everyone: one bad level " + + "would take out the whole lobby rather than one player. Custom/shared maps must " + + "use current format 29; older maps are accepted only when their SHA-256 exactly " + + "matches a level in this installation's bundled catalogue. Leave this on."); CreationMode = Config.Bind("CreationMode", "Enabled", true, "Keep the level editor working by saving levels to this machine instead of Bossa's " + "UGC service. Without it, loading into Creation Mode hangs on a black screen: the " @@ -114,6 +125,22 @@ private void Awake() + "complete or fail. Levels go to the SS2Revive folder beside your other saves, one " + "folder each. Publishing works, but only you can see the result - there is no " + "shared level browser left to publish to."); + LevelSharingEnabled = Config.Bind("CreationMode", "LevelSharing", true, + "Turn the terminal's Share button into an Export button, and add an Import button " + + "to the Create screen. Export writes the level to one .ss2level file and copies " + + "the game's own 22-character share code to the clipboard; import reads any " + + ".ss2level file left in the import folder. Both folders sit beside your saves, in " + + "the SS2Revive folder. Send the file however you like and post the code with it - " + + "the terminal's search box has always accepted a code, so once somebody has " + + "imported the file, the code finds the level on their machine too. Imported " + + "levels arrive published and credited to whoever built them, so they can be " + + "played and browsed but not edited."); + CommunityCatalogUrl = Config.Bind("CreationMode", "CommunityCatalogUrl", "", + "Optional HTTPS URL of a curated SS2Revive community-map catalog.json. When set, " + + "published maps from that bounded static catalogue are merged into Discover. " + + "Bundles and thumbnails must be relative objects beside the catalogue; they " + + "are checksum-verified, cached, and installed locally only when opened. Leave " + + "empty for a completely local library."); FreeForAll = Config.Bind("FreeForAll", "Enabled", true, "Draw the Free-for-all queue from the levels on this machine. Bossa served that " + "queue from a curated slice of what the community had published, so without this " diff --git a/src/SS2Revive/SS2Revive.csproj b/src/SS2Revive/SS2Revive.csproj index 5567134..d815b40 100644 --- a/src/SS2Revive/SS2Revive.csproj +++ b/src/SS2Revive/SS2Revive.csproj @@ -108,6 +108,24 @@ $(GameManagedDir)\UnityEngine.InputLegacyModule.dll false + + + + $(GameManagedDir)\UnityEngine.IMGUIModule.dll + false + + + $(GameManagedDir)\UnityEngine.UI.dll + false + + + $(GameManagedDir)\Unity.TextMeshPro.dll + false + - 1.1.0 + 1.2.0