diff --git a/.gitea/workflows/renovate.yaml b/.gitea/workflows/renovate.yaml index e0370a1a..1768608d 100644 --- a/.gitea/workflows/renovate.yaml +++ b/.gitea/workflows/renovate.yaml @@ -29,16 +29,16 @@ jobs: LOG_LEVEL: ${{ github.event.inputs.debug == 'true' && 'debug' || 'info' }} shell: bash run: | - docker run --rm \ - --memory=6g \ - --memory-swap=6g \ - -e RENOVATE_PLATFORM=gitea \ - -e RENOVATE_ENDPOINT=https://gitlab.epicnabbo.nl/ \ - -e RENOVATE_TOKEN \ - -e RENOVATE_REPOSITORIES='["remco/EpicNext-Cms"]' \ - -e RENOVATE_AUTODISCOVER=false \ - -e RENOVATE_BINARY_SOURCE=install \ - -e LOG_LEVEL \ - -e RENOVATE_ONBOARDING=true \ - -e RENOVATE_REQUIRE_CONFIG=required \ - "renovate/renovate:${RENOVATE_VERSION}" + docker run --rm \ + --memory=6g \ + --memory-swap=6g \ + -e RENOVATE_PLATFORM=gitea \ + -e RENOVATE_ENDPOINT=https://gitlab.epicnabbo.nl/ \ + -e RENOVATE_TOKEN \ + -e RENOVATE_REPOSITORIES='["remco/EpicNext-Cms"]' \ + -e RENOVATE_AUTODISCOVER=false \ + -e RENOVATE_BINARY_SOURCE=install \ + -e LOG_LEVEL \ + -e RENOVATE_ONBOARDING=true \ + -e RENOVATE_REQUIRE_CONFIG=required \ + "renovate/renovate:${RENOVATE_VERSION}" diff --git a/CATALOG_EXPORT.md b/CATALOG_EXPORT.md index 78893378..d69639f5 100644 --- a/CATALOG_EXPORT.md +++ b/CATALOG_EXPORT.md @@ -26,15 +26,15 @@ This source change alone does not configure or deploy the production service. ## Exported content -| Source | Repository destination | -| ------------------------------------------------------------------------ | --------------------------------------------------- | -| Configured Nitro bundles, including furniture, figures, effects and pets | `Gamedata/bundled` | -| Configured furni icons | `Gamedata/icons` | -| Existing badge/catalog images | `Gamedata/c_images` | -| FurnitureData and supported public game-data JSON files | `Gamedata/config` | -| Existing localized FurnitureData files | `catalogue version 2 ( Final (Dev)/langs furnidata` | -| `items_base`, `catalog_pages`, `catalog_items` | `catalogue version 2 ( Final (Dev)/sqls` | -| `catalog_pages_bc`, `catalog_items_bc`, when present | Same SQL directory | +| Source | Repository destination | +| --- | --- | +| Configured Nitro bundles, including furniture, figures, effects and pets | `Gamedata/bundled` | +| Configured furni icons | `Gamedata/icons` | +| Existing badge/catalog images | `Gamedata/c_images` | +| FurnitureData and supported public game-data JSON files | `Gamedata/config` | +| Existing localized FurnitureData files | `catalogue version 2 ( Final (Dev)/langs furnidata` | +| `items_base`, `catalog_pages`, `catalog_items` | `catalogue version 2 ( Final (Dev)/sqls` | +| `catalog_pages_bc`, `catalog_items_bc`, when present | Same SQL directory | SQL is read in one consistent, read-only InnoDB transaction. Dumps contain table definitions and deterministic upserts with hexadecimal UTF-8 string literals. diff --git a/README.md b/README.md index 8ab3e281..4238922b 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,15 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou ## System Requirements -| Component | Version | Notes | -| --------------- | ------------ | --------------------------------------- | -| Node.js | 26.12.1 | Pinned in `.nvmrc` (`>=26.12.1 <27`) | -| pnpm | 12.11.2 | Pinned via `packageManager` | -| npm | >= 11.x | Supported alternative | -| yarn | >= 4.x | Supported alternative | -| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator | -| Docker | 24+ | Optional — for containerized deployment | -| Valkey | 8.x+ | Optional — caching, rate limiting, SSE | +| Component | Version | Notes | +| --------------- | -------------- | ---------------------------------------- | +| Node.js | 26.12.1 | Pinned in `.nvmrc` (`>=26.12.1 <27`) | +| pnpm | 12.11.2 | Pinned via `packageManager` | +| npm | >= 11.x | Supported alternative | +| yarn | >= 4.x | Supported alternative | +| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator | +| Docker | 24+ | Optional — for containerized deployment | +| Valkey | 8.x+ | Optional — caching, rate limiting, SSE | Core stack: **Next.js 16.4.2** (App Router) · **React 19.3.1** · **TypeScript 7.0.2** · **Drizzle ORM 0.45.4** · **Zod 4.6.7** · **Vitest 5.0.5** · **Biome 2.5.15** · **Playwright 1.64.1**. @@ -292,14 +292,14 @@ cannot be changed simply by injecting a new variable into an old container. ### Docker commands -| Command | Purpose | -| ------------------------------- | ----------------------------------------- | +| Command | Purpose | +| --- | --- | | `bash scripts/docker-update.sh` | Pull, build, migrate, recreate and verify | -| `docker compose logs -f cms` | View CMS logs | -| `docker compose exec cms sh` | Open a shell in the running CMS | -| `docker compose restart cms` | Restart the existing release | -| `docker compose down` | Stop services; does not update code | -| `pnpm db:up` / `pnpm db:down` | Manage the optional MariaDB service | +| `docker compose logs -f cms` | View CMS logs | +| `docker compose exec cms sh` | Open a shell in the running CMS | +| `docker compose restart cms` | Restart the existing release | +| `docker compose down` | Stop services; does not update code | +| `pnpm db:up` / `pnpm db:down` | Manage the optional MariaDB service | For a manual build, export `CMS_RELEASE=$(git rev-parse HEAD)` before `docker compose build cms`; deployment and migration verification remain your @@ -311,7 +311,7 @@ application build layer. Deleting all Docker cache is not an update mechanism. The image never installs a pinned pnpm of its own. The Dockerfile installs [corepack](https://nodejs.org/api/corepack.html) and lets it resolve the pnpm -version _and_ its sha256/sha512 hash from the `packageManager` field in +version *and* its sha256/sha512 hash from the `packageManager` field in `package.json`. One pin therefore drives the Docker build, the CI runner and the host — the image can no longer drift away from the lockfile, and a tampered package tarball is rejected instead of silently installed. Bumping pnpm is a @@ -319,6 +319,7 @@ single edit in `package.json` (plus the README version table, checked by `pnpm readme:versions:check`). Two caches make repeated builds cheap: + - **pnpm store** — a BuildKit cache mount on `$PNPM_HOME/store` keeps the downloaded packages between builds. A lockfile change then re-uses the store (`reused 493, downloaded 0`) instead of re-downloading every package. @@ -350,22 +351,22 @@ error in test code break the image build and pulled `@playwright/test`, `vitest` Only the CMS (and the optional avatar imager container, see [Avatar Imaging](#avatar-imaging)) run in Docker. The heavy client assets and gamedata stay on the host and are shared into the container so imports and uploads persist: -| Container Path | Host Path | Mode | Purpose | -| -------------------------- | ----------------------- | ---- | ------------------------------------- | -| `/app/public/nitro-assets` | `./public/nitro-assets` | rw | Imported furni/pet/effect assets | -| `/app/public/swf` | `./public/swf` | rw | SWF costumes / icons (imports) | -| `/app/storage` | `./storage` | rw | Uploaded media (persistent) | -| `/var/www/Gamedata` | `/var/www/Gamedata` | rw | Shared gamedata root (hardcoded path) | +| Container Path | Host Path | Mode | Purpose | +| -------------------------- | -------------------------- | ---- | ------------------------------------ | +| `/app/public/nitro-assets` | `./public/nitro-assets` | rw | Imported furni/pet/effect assets | +| `/app/public/swf` | `./public/swf` | rw | SWF costumes / icons (imports) | +| `/app/storage` | `./storage` | rw | Uploaded media (persistent) | +| `/var/www/Gamedata` | `/var/www/Gamedata` | rw | Shared gamedata root (hardcoded path)| **About the hardcoded `/var/www/Gamedata` path:** `src/lib/services/furni-asset-dirs.ts` defines `DEFAULT_GAMEDATA_ROOT = /var/www/Gamedata` as an absolute on-disk path the CMS reads and mirrors imported assets into. The compose file mounts the host `/var/www/Gamedata` at the identical path inside the container so `existsSync('/var/www/Gamedata')` succeeds and nginx keeps serving `/gamedata/` from the same directory. **The Nitro/Octane client and renderer are NOT mounted** — nginx on the host serves them directly: -| Component | Host path | How it's served | -| ------------------- | ------------------------ | ------------------------------------------------------------------------------------------------- | -| Nitro/Octane client | `/var/www/Octane/dist` | nginx `location ^~ /client/` and `/nitro-client/` alias | -| Avatar imager | `/docker/Polaris-imager` | Docker container `avatar-imaging-pixinode` on port `8082` — see [Avatar Imaging](#avatar-imaging) | -| Camera uploads | `/var/www/Camera` | nginx `/camera/` alias | +| Component | Host path | How it's served | +| -------------------- | ----------------------- | ---------------------------------------------------------------------- | +| Nitro/Octane client | `/var/www/Octane/dist` | nginx `location ^~ /client/` and `/nitro-client/` alias | +| Avatar imager | `/docker/Polaris-imager`| Docker container `avatar-imaging-pixinode` on port `8082` — see [Avatar Imaging](#avatar-imaging) | +| Camera uploads | `/var/www/Camera` | nginx `/camera/` alias | **Container user & write permissions:** the CMS container runs as `www-data` (UID/GID 33) by default to match the host owner of `/var/www/Gamedata`. Ensure the other write volumes (`./public/nitro-assets`, `./public/swf`, `./storage`) are also owned by `www-data` on the host: @@ -385,7 +386,7 @@ docker compose build --build-arg RUN_USER=1000 The CMS container runs with `network_mode: host`. This lets the existing `127.0.0.1` references in `.env` keep working against host services — MariaDB (`3306`), Valkey/Redis (`6379`), the emulator RCON/API (`3003`/`3001`) and the avatar imager — without re-writing any environment variables. The container consequently listens **directly on the host's port 3002**, so stop any previous host-side CMS (e.g. `pm2 stop next`) that occupies that port before starting it. -The **build** deliberately does _not_ use host networking. BuildKit (v0.26, Docker 29) +The **build** deliberately does *not* use host networking. BuildKit (v0.26, Docker 29) refuses to grant host networking unless every caller passes `--allow=network.host`, and `docker compose build` has no such flag — asking for `build.network: host` therefore turned every rebuild into an immediate @@ -410,6 +411,7 @@ pnpm db:up # docker compose --profile db up -d (starts mariadb-turbo) ``` Key settings (see the `command:` block in `docker-compose.yml`): + - `max_allowed_packet = 512M` — 50 MB JSON batches no longer hit the 16 MB packet ceiling. - `innodb_flush_log_at_trx_commit = 2` + `innodb_doublewrite = 0` — durability relaxed for bulk writes. - `innodb_buffer_pool_size = 2G`, `innodb_log_file_size = 1G`, `bulk_insert_buffer_size = 512M`. @@ -422,7 +424,7 @@ The datadir lives in a **named volume** (`mariadb-turbo-data`), never a host bin #### node_modules & host-sync corruption -pnpm's store is hard-linked and its `.bin` shims are symlinks, so **node_modules must never be a host bind-mount** (Docker Desktop gRPC-FUSE/VirtioFS, Unison and Syncthing all corrupt it). The production build bakes `node_modules` into the image at build time; it is never bind-mounted. For local dev, mount a _named volume_ (`node_modules:/app/node_modules`) instead of the host directory, and keep `node_modules` / the pnpm store out of any shared-filesystem bind. +pnpm's store is hard-linked and its `.bin` shims are symlinks, so **node_modules must never be a host bind-mount** (Docker Desktop gRPC-FUSE/VirtioFS, Unison and Syncthing all corrupt it). The production build bakes `node_modules` into the image at build time; it is never bind-mounted. For local dev, mount a *named volume* (`node_modules:/app/node_modules`) instead of the host directory, and keep `node_modules` / the pnpm store out of any shared-filesystem bind. #### Loading a 50 MB JSON dump @@ -437,7 +439,8 @@ pnpm db:bulk --file=/data/products.json --table=docs --category=furni pnpm db:bulk --file=/data/external_texts.json --table=texts --category=default ``` -The importer (`scripts/bulk-import-json.ts`) maps rows onto `src/db/schema-gamedata.ts`, batches them into **2000-row multi-row INSERTs** (one statement per chunk), uses `ON DUPLICATE KEY UPDATE` so re-runs are idempotent and interrupted loads resume, and prints rows/s + ETA. See ["ORM Setup & Type Generation"](#orm-setup--type-generation) → _Bulk JSON storage_ for the design. +The importer (`scripts/bulk-import-json.ts`) maps rows onto `src/db/schema-gamedata.ts`, batches them into **2000-row multi-row INSERTs** (one statement per chunk), uses `ON DUPLICATE KEY UPDATE` so re-runs are idempotent and interrupted loads resume, and prints rows/s + ETA. See ["ORM Setup & Type Generation"](#orm-setup--type-generation) → *Bulk JSON storage* for the design. + --- @@ -452,47 +455,50 @@ import { db } from "@/lib/db"; import { users } from "@/db/schema"; import { eq } from "drizzle-orm/expressions"; -const found = await db.select().from(users).where(eq(users.username, "hello")); +const found = await db.select() + .from(users) + .where(eq(users.username, "hello")); ``` **Drizzle CLI** (`drizzle-kit`) is used for local development tasks — it is a devDependency and is never bundled in production. -| Command | What it does | -| ------------------------- | --------------------------------------------------------------------------------------- | -| `pnpm db:generate` | Draft SQL from Drizzle schema into `drizzle/drafts/` (review + copy) | -| `pnpm db:studio` | Open Drizzle Studio (dev only) | -| `pnpm db:introspect` | Reverse-engineer an existing DB into a Drizzle schema draft | -| `pnpm db:bulk` | Batch-import >50 MB JSON (2000-row chunks, resumable) via `scripts/bulk-import-json.ts` | -| `pnpm db:schema:generate` | Regen committed `src/db/schema.ts` from previous schema + live DB | +| Command | What it does | +| ---------------------- | ---------------------------------------------------------------------- | +| `pnpm db:generate` | Draft SQL from Drizzle schema into `drizzle/drafts/` (review + copy) | +| `pnpm db:studio` | Open Drizzle Studio (dev only) | +| `pnpm db:introspect` | Reverse-engineer an existing DB into a Drizzle schema draft | +| `pnpm db:bulk` | Batch-import >50 MB JSON (2000-row chunks, resumable) via `scripts/bulk-import-json.ts` | +| `pnpm db:schema:generate` | Regen committed `src/db/schema.ts` from previous schema + live DB | > The CMS does **not** use `drizzle-kit push` or `drizzle-kit migrate` — the database is shared with the emulator. Apply CMS DDL only via `pnpm db:migrate`. #### Bulk JSON storage (MariaDB) Emulator/hotel JSON dumps (furnidata, external texts, product data) are often >50 MB. MariaDB's `JSON` type is an alias for `LONGTEXT` and can **never be indexed directly**, so `src/db/schema-gamedata.ts` follows the "promote + index" pattern: + - The raw JSON document stays intact in a `longtext` / `mediumtext` column (`payload`, `value`). - The fields you actually `WHERE` / `ORDER BY` on are promoted into real columns (`sprite_id`, `class_name`, `kind`, `title`, `text_key`) and indexed. - Optional **VIRTUAL generated columns** extract indexed fields from the payload with `JSON_EXTRACT` (MariaDB supports secondary indexes on virtual columns, 10.2+), so no duplicate data has to be written by the importer. Three tables are exported: -| Table | Purpose | Unique key | -| -------------------- | ------------------------------------------------------------ | ---------------------- | -| `gamedata_furnidata` | One row per furni/clothing item (habbo furnidata_json shape) | `(source, sprite_id)` | -| `gamedata_docs` | Generic JSON document store (per-key documents) | `(category, doc_key)` | -| `gamedata_texts` | External-texts style key/value pairs | `(category, text_key)` | +| Table | Purpose | Unique key | +| -------------------- | ---------------------------------------------- | --------------------------- | +| `gamedata_furnidata` | One row per furni/clothing item (habbo furnidata_json shape) | `(source, sprite_id)` | +| `gamedata_docs` | Generic JSON document store (per-key documents) | `(category, doc_key)` | +| `gamedata_texts` | External-texts style key/value pairs | `(category, text_key)` | -The bulk importer (`scripts/bulk-import-json.ts`, run via `pnpm db:bulk`) is DB-driven and fast precisely because each chunk is a _single_ multi-row `INSERT … VALUES () ()… ON DUPLICATE KEY UPDATE`, so a 50 MB dump is a few dozen statements instead of hundreds of thousands of round-trips. Flags: +The bulk importer (`scripts/bulk-import-json.ts`, run via `pnpm db:bulk`) is DB-driven and fast precisely because each chunk is a *single* multi-row `INSERT … VALUES () ()… ON DUPLICATE KEY UPDATE`, so a 50 MB dump is a few dozen statements instead of hundreds of thousands of round-trips. Flags: -| Flag | Default | Description | -| ---------------- | ------------ | ---------------------------------------------- | -| `--file=` | — (required) | JSON file (habbo furnidata_json or flat array) | -| `--table=` | `furnidata` | One of `furnidata` \| `docs` \| `texts` | -| `--source=` | `habbo` | `source` value for `furnidata` | -| `--category=` | `default` | `category` value for `docs` / `texts` | -| `--chunk-size=N` | `2000` | Rows per multi-row INSERT | -| `--limit=N` | `0` | Stop after N rows (dry-test) | -| `--truncate` | off | DELETE rows for this source/category first | +| Flag | Default | Description | +| -------------------- | ----------- | ------------------------------------------------- | +| `--file=` | — (required)| JSON file (habbo furnidata_json or flat array) | +| `--table=` | `furnidata` | One of `furnidata` \| `docs` \| `texts` | +| `--source=` | `habbo` | `source` value for `furnidata` | +| `--category=` | `default` | `category` value for `docs` / `texts` | +| `--chunk-size=N` | `2000` | Rows per multi-row INSERT | +| `--limit=N` | `0` | Stop after N rows (dry-test) | +| `--truncate` | off | DELETE rows for this source/category first | The script sets `FOREIGN_KEY_CHECKS=0` for the session and is safe to interrupt: each chunk commits on its own, and `ON DUPLICATE KEY UPDATE` makes re-runs overwrite instead of appending. @@ -737,13 +743,13 @@ For bulk-written tables (game-data JSON, imports) a containerized **`mariadb-tur ### 3. Asset caching headers -| Path | Cache-Control | -| ----------------------------- | -------------------------------------------------------- | -| `/swf/**`, `/nitro-assets/**` | `public, max-age=604800, stale-while-revalidate=2592000` | -| `/gamedata/**`, `/client/**` | `public` + `expires 7–30 d` (nginx) | -| `/imaging/*` | `public, max-age=300` | -| `/camera/**` | `public, max-age=31536000, immutable` | -| `/_next/static/**` | `public, max-age=31536000, immutable` | +| Path | Cache-Control | +| ------------------------------ | -------------------------------------------------------------- | +| `/swf/**`, `/nitro-assets/**` | `public, max-age=604800, stale-while-revalidate=2592000` | +| `/gamedata/**`, `/client/**` | `public` + `expires 7–30 d` (nginx) | +| `/imaging/*` | `public, max-age=300` | +| `/camera/**` | `public, max-age=31536000, immutable` | +| `/_next/static/**` | `public, max-age=31536000, immutable` | ### 4. Cache layers (Redis + CDN) @@ -762,15 +768,16 @@ handlers, and its response helper then drops the `Cache-Control` set by free of `auth()`, `cookies()`, `headers()` and request data, so the edge may cache them on their behalf. -| Class | Endpoints | TTL | -| ----- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---- | -| 1 | `/api/online`, `/api/online/count` | 10s | -| 2 | `/api/photos`, `/api/leaderboard`, `/api/radio/current-dj`, `/api/radio/points/leaderboard` | 60s | -| 3 | `/api/staff`, `/api/teams`, `/api/guilds`, `/api/shop`, `/api/shop/categories`, `/api/values`, `/api/values/categories`, `/api/values/[0-9]+` | 300s | +| Class | Endpoints | TTL | +| --- | --- | --- | +| 1 | `/api/online`, `/api/online/count` | 10s | +| 2 | `/api/photos`, `/api/leaderboard`, `/api/radio/current-dj`, `/api/radio/points/leaderboard` | 60s | +| 3 | `/api/staff`, `/api/teams`, `/api/guilds`, `/api/shop`, `/api/shop/categories`, `/api/values`, `/api/values/categories`, `/api/values/[0-9]+` | 300s | `/api/radio/shouts` is intentionally excluded: it is live chat. Three details are easy to get wrong and are worth keeping intact: + - **`proxy_ignore_headers "Cache-Control" "Vary";` is what makes the cache work.** `proxy_hide_header` only strips the header from the response to the client; the cache module has already read the upstream's `no-store` by then and stores @@ -791,21 +798,21 @@ with a redirect to `/me` and reads `headers()` for a per-request CSP nonce. ### Files changed for stability and caching -| File | Change | -| -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/etc/nginx/nginx.conf` | `worker_connections 4096`, `multi_accept on`, TLS 1.2/1.3 only, `proxy_cache_path` for the micro-cache | -| `/etc/nginx/sites-enabled/cms.conf` | single correct `Cache-Control` per path, micro-cache on the whitelist, session/Authorization bypass, SSE block with buffering off, `upstream cms_app` + `proxy_next_upstream` | -| `/etc/nginx/snippets/cms_upstream_servers.conf` | backend list, rewritten by `ci-deploy.sh` during a cutover | -| `src/app/api/online/count/stream/route.ts` | `X-Accel-Buffering: no` | -| `src/app/api/radio/stream/route.ts` | `X-Accel-Buffering: no` | -| `src/app/api/admin/import/{audit,furni/route,furni/batch,furni/batch-regen,furni/repair-icons}/route.ts` | `X-Accel-Buffering: no` | -| `src/app/api/admin/studio/nitro-cleanup{,/rebuild}/route.ts` | `X-Accel-Buffering: no` | -| `scripts/ci-deploy.sh` | resource limits, blue/green cutover, `switch_upstream`, rollback split by cutover state | -| `deploy.sh` | reduced to a wrapper around `ci-deploy.sh` | -| `docker-compose.yml` | two replica services from a shared anchor, resource limits, port-aware healthcheck, dead `mariadb-turbo` removed | -| `src/lib/services/cache-warmup.ts` | corrected a comment that described delays the code never had | -| `src/lib/ci-deploy.test.ts`, `src/test/ci-deploy-harness.sh` | blue/green coverage; nginx paths are injectable so the in-place path stays tested | -| `ecosystem.config.cjs` | removed (PM2 supervised no process, and nothing referenced it) | +| File | Change | +| --- | --- | +| `/etc/nginx/nginx.conf` | `worker_connections 4096`, `multi_accept on`, TLS 1.2/1.3 only, `proxy_cache_path` for the micro-cache | +| `/etc/nginx/sites-enabled/cms.conf` | single correct `Cache-Control` per path, micro-cache on the whitelist, session/Authorization bypass, SSE block with buffering off, `upstream cms_app` + `proxy_next_upstream` | +| `/etc/nginx/snippets/cms_upstream_servers.conf` | backend list, rewritten by `ci-deploy.sh` during a cutover | +| `src/app/api/online/count/stream/route.ts` | `X-Accel-Buffering: no` | +| `src/app/api/radio/stream/route.ts` | `X-Accel-Buffering: no` | +| `src/app/api/admin/import/{audit,furni/route,furni/batch,furni/batch-regen,furni/repair-icons}/route.ts` | `X-Accel-Buffering: no` | +| `src/app/api/admin/studio/nitro-cleanup{,/rebuild}/route.ts` | `X-Accel-Buffering: no` | +| `scripts/ci-deploy.sh` | resource limits, blue/green cutover, `switch_upstream`, rollback split by cutover state | +| `deploy.sh` | reduced to a wrapper around `ci-deploy.sh` | +| `docker-compose.yml` | two replica services from a shared anchor, resource limits, port-aware healthcheck, dead `mariadb-turbo` removed | +| `src/lib/services/cache-warmup.ts` | corrected a comment that described delays the code never had | +| `src/lib/ci-deploy.test.ts`, `src/test/ci-deploy-harness.sh` | blue/green coverage; nginx paths are injectable so the in-place path stays tested | +| `ecosystem.config.cjs` | removed (PM2 supervised no process, and nothing referenced it) | > The nginx files live on the host only, not in this repository. Rebuilding the > host loses the cache configuration — keep a copy under version control if that @@ -817,6 +824,7 @@ with a redirect to `/me` and reads `headers()` for a per-request CSP nonce. Two layers work together to keep the origin alive during misuse, floods, and repeat-offender attacks: + 1. **App-layer gate** (production only) — per-IP request buckets per category (pages / API / auth), a whole-site safety valve, and escalation tiers that temporarily hard-block offending IPs (`src/lib/ddos-guard.ts`). @@ -838,7 +846,7 @@ repeat-offender attacks: - A Cloudflare API token with the following zone permissions: - `Zone > Zone > Read` - `Zone > Firewall > Edit` - Create one at **My Profile → API Tokens → Create Token** (custom token). + Create one at **My Profile → API Tokens → Create Token** (custom token). ### 1. Environment variables @@ -874,6 +882,7 @@ Restart the container after changing them. Open **DevOps → Anti-DDoS protection** (`/admin/devops/antiddos`, requires `settings.view`) and: + - Check the **Cloudflare** card shows the request arrived proxied, and the green **API configured** badge. - Click **Verify connection** — it confirms the token can read the zone and @@ -909,18 +918,19 @@ read one thing, read the precedence table below. ### Where the captcha is configured (and who wins) There are two places, and the **admin panel always wins**: -1. **Admin panel** — _Admin → CMS Settings → Captcha_ (stored in the database). + +1. **Admin panel** — *Admin → CMS Settings → Captcha* (stored in the database). 2. **Environment variables** — used **only** as a fallback when the admin panel has no value. Resolution order, highest first: -| Admin panel `captcha_provider` | Env `CAPTCHA_PROVIDER` | What happens | -| ------------------------------ | ---------------------- | -------------------------------------- | -| `turnstile` | anything | **Turnstile** (panel wins) | -| `none` (set explicitly) | `turnstile` | **Off** — an explicit panel value wins | -| _empty / never set_ | `turnstile` | **Turnstile** (env fallback) | -| _empty / never set_ | _empty / never set_ | **Off** | +| Admin panel `captcha_provider` | Env `CAPTCHA_PROVIDER` | What happens | +| --- | --- | --- | +| `turnstile` | anything | **Turnstile** (panel wins) | +| `none` (set explicitly) | `turnstile` | **Off** — an explicit panel value wins | +| *empty / never set* | `turnstile` | **Turnstile** (env fallback) | +| *empty / never set* | *empty / never set* | **Off** | In short: **enable it in the admin panel and the env vars are ignored; leave the panel empty and the env vars take over.** An explicit `none` in the panel turns @@ -931,7 +941,7 @@ the captcha **off** even when `CAPTCHA_PROVIDER=turnstile` is set. 1. Create a Turnstile widget in the [Cloudflare dashboard](https://dash.cloudflare.com/?to=/:account/turnstile): **Add widget**, name it, and add the hostnames your hotel is served on - (e.g. `epicnabbo.nl`, `www.epicnabbo.nl`). _Managed_ mode is fine. Copy the + (e.g. `epicnabbo.nl`, `www.epicnabbo.nl`). *Managed* mode is fine. Copy the **Site Key** and the **Secret Key**. 2. Put them in your real `.env` (never commit this file): @@ -996,10 +1006,10 @@ port rather than sharing a published docker port. `docker compose up --scale cms=2` therefore cannot work here: the replicas would collide on 3002. Instead there are two fixed slots, and each release lands in the idle one: -| Slot | Host port | Container name | -| ---- | --------- | -------------------- | -| A | 3002 | `epicnext-cms-app` | -| B | 3003 | `epicnext-cms-green` | +| Slot | Host port | Container name | +| --- | --- | --- | +| A | 3002 | `epicnext-cms-app` | +| B | 3003 | `epicnext-cms-green` | `docker-compose.yml` defines a single `cms` service (the blue slot on 3002) behind the `cms` profile, so a bare `docker compose up -d` never starts it. The @@ -1017,6 +1027,7 @@ cron) parked an `epicnext-cms` container on the blue slot while nginx served the green slot, and every later release stopped on "Port 3002 is already in use" — after the build, the migrations and the browser gate. Three guards now prevent that: + - The `cms` service sits behind the `cms` profile, so a bare `docker compose up -d` starts only byparr and can never claim 3002. The service starts only when it is named explicitly (`docker compose up -d cms`), which is what the @@ -1027,7 +1038,7 @@ that: stopped and deleted, so the guard stopped firing while the host stayed CI-managed. It now checks both slot containers and the nginx upstream, and reports the skip with exit 0 instead of failing the cron. -- `ci-deploy.sh` retires a compose replica of _this_ checkout +- `ci-deploy.sh` retires a compose replica of *this* checkout (`com.docker.compose.project.config_files`) from the candidate port before starting the candidate — but never a slot container, and never the port nginx currently serves. Anything else still fails loudly in `assert_port_free`. @@ -1077,7 +1088,7 @@ means a single leak can starve MariaDB, nginx and Traefik. > **Watch this on the first heavy import.** The 4 GiB ceiling is not yet > exercised by a real bulk import. If the container is OOM-killed during a large -> furniture import, raise `mem_limit` in `docker-compose.yml` _and_ `mem_limit` / +> furniture import, raise `mem_limit` in `docker-compose.yml` *and* `mem_limit` / > `mem_swap_limit` in `scripts/ci-deploy.sh` together. ### Manual deploys @@ -1109,18 +1120,18 @@ cap larger than the free memory from dragging the whole host into the global OOM-killer. `MemorySwapMax=0` is set explicitly (this host has no swap). If fewer than 1 GB would remain, the script refuses to start. -| Script | Ceiling | Covers | -| ----------------------- | ------- | -------------------------- | -| `pnpm dev` | 8 GB | Dev server | -| `pnpm build` | 10 GB | Production build | -| `pnpm analyze` | 10 GB | Build + bundle-size report | -| `pnpm test` | 8 GB | Vitest | -| `pnpm test:coverage` | 8 GB | Vitest with coverage | -| `pnpm test:ui` | 8 GB | Playwright | -| `pnpm test:e2e` | 8 GB | Playwright | -| `pnpm test:e2e:quality` | 8 GB | Playwright (advisory) | -| `pnpm test:integration` | 8 GB | Vitest integration config | -| `pnpm typecheck` | 6 GB | `tsc --noEmit` | +| Script | Ceiling | Covers | +| --------------------- | ------- | --------------------------------------- | +| `pnpm dev` | 8 GB | Dev server | +| `pnpm build` | 10 GB | Production build | +| `pnpm analyze` | 10 GB | Build + bundle-size report | +| `pnpm test` | 8 GB | Vitest | +| `pnpm test:coverage` | 8 GB | Vitest with coverage | +| `pnpm test:ui` | 8 GB | Playwright | +| `pnpm test:e2e` | 8 GB | Playwright | +| `pnpm test:e2e:quality` | 8 GB | Playwright (advisory) | +| `pnpm test:integration` | 8 GB | Vitest integration config | +| `pnpm typecheck` | 6 GB | `tsc --noEmit` | ### Running the builder by hand @@ -1147,18 +1158,18 @@ Use the capped build instead: ``` Fix: use `pnpm build`. If you are genuinely inside an isolated environment -where the container _is_ the boundary (the Docker build, a CI runner), set +where the container *is* the boundary (the Docker build, a CI runner), set `CMS_MEMORY_CAPPED=1` to opt out deliberately. ### Backends `scripts/with-memory-cap.sh` picks its mechanism automatically: -| `CMS_MEMORY_CAP_BACKEND` | Mechanism | Notes | -| ------------------------ | -------------------------- | ------------------------------------------------------ | -| `auto` _(default)_ | systemd cgroup `MemoryMax` | Real RSS bound over the whole process tree. Used here. | -| `ulimit` | `ulimit -v`, per process | Virtual address space, **not** RSS. Fallback only. | -| `none` | None — warning only | Docker build and GitLab runner, each already isolated. | +| `CMS_MEMORY_CAP_BACKEND` | Mechanism | Notes | +| ------------------------ | ------------------------------------------- | ------------------------------------------------------- | +| `auto` *(default)* | systemd cgroup `MemoryMax` | Real RSS bound over the whole process tree. Used here. | +| `ulimit` | `ulimit -v`, per process | Virtual address space, **not** RSS. Fallback only. | +| `none` | None — warning only | Docker build and GitLab runner, each already isolated. | On a host **without** systemd and without `CMS_MEMORY_CAP_BACKEND=none`, the script refuses to run rather than proceeding unbounded. @@ -1176,40 +1187,40 @@ the headroom left for everything else on the machine; the effective ceiling is ## Scripts -| Command | Description | -| ----------------------------- | ---------------------------------------------------------- | -| `pnpm dev` | Start development server (hot reload) | -| `pnpm build` | Production build (memory-capped) | -| `pnpm start` | Start production server | -| `pnpm typecheck` | Run TypeScript type checking | -| `pnpm test` | Run all tests (Vitest) | -| `pnpm test:coverage` | Run all tests with coverage thresholds enforced | -| `pnpm test:ui` | Playwright UI tests (`playwright.ui.config.ts`) | -| `pnpm test:ui:update` | Playwright UI tests, updating snapshots | -| `pnpm test:e2e` | Playwright end-to-end tests (blocking deploy gate) | -| `pnpm test:e2e:quality` | Advisory content/latency checks (non-blocking) | -| `pnpm test:integration` | Integration tests (own Vitest config) | -| `pnpm test:housekeeping` | Housekeeping feature tests | -| `pnpm lint` | Lint and format check (Biome) | -| `pnpm biome:lint` | Alias of `pnpm lint` | -| `pnpm format` | Format files in place (Biome) | -| `pnpm toolchain:check` | Verify the Node toolchain matches `.nvmrc` | -| `pnpm i18n:check` | Audit CMS translation coverage | -| `pnpm deps:audit` | Audit dependencies for high-severity advisories | -| `pnpm db:migrate` | Apply pending SQL migrations | -| `pnpm db:migrate:status` | Show migration status | -| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from prior schema + live DB | -| `pnpm db:generate` | Draft SQL via drizzle-kit → `drizzle/drafts/` | -| `pnpm db:studio` | Drizzle Studio (dev) | -| `pnpm db:introspect` | drizzle-kit introspect (draft) | -| `pnpm db:bulk` | Batch-import >50 MB JSON via `scripts/bulk-import-json.ts` | -| `pnpm catalog:sync` | Sync the catalog against a clone source (interactive) | -| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` container | -| `pnpm gamedata:compress` | Pre-compress large gamedata JSON to `.gz` (gzip_static) | -| `pnpm analyze` | Build + report per-route bundle sizes | -| `pnpm performance:report` | Re-render the performance report from a build | -| `pnpm jobs:worker` | Start background task worker | -| `pnpm assets:editor` | Copy TinyMCE editor assets into `public/` | +| Command | Description | +| ------------------------- | -------------------------------------------------- | +| `pnpm dev` | Start development server (hot reload) | +| `pnpm build` | Production build (memory-capped) | +| `pnpm start` | Start production server | +| `pnpm typecheck` | Run TypeScript type checking | +| `pnpm test` | Run all tests (Vitest) | +| `pnpm test:coverage` | Run all tests with coverage thresholds enforced | +| `pnpm test:ui` | Playwright UI tests (`playwright.ui.config.ts`) | +| `pnpm test:ui:update` | Playwright UI tests, updating snapshots | +| `pnpm test:e2e` | Playwright end-to-end tests (blocking deploy gate) | +| `pnpm test:e2e:quality` | Advisory content/latency checks (non-blocking) | +| `pnpm test:integration` | Integration tests (own Vitest config) | +| `pnpm test:housekeeping` | Housekeeping feature tests | +| `pnpm lint` | Lint and format check (Biome) | +| `pnpm biome:lint` | Alias of `pnpm lint` | +| `pnpm format` | Format files in place (Biome) | +| `pnpm toolchain:check` | Verify the Node toolchain matches `.nvmrc` | +| `pnpm i18n:check` | Audit CMS translation coverage | +| `pnpm deps:audit` | Audit dependencies for high-severity advisories | +| `pnpm db:migrate` | Apply pending SQL migrations | +| `pnpm db:migrate:status` | Show migration status | +| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from prior schema + live DB | +| `pnpm db:generate` | Draft SQL via drizzle-kit → `drizzle/drafts/` | +| `pnpm db:studio` | Drizzle Studio (dev) | +| `pnpm db:introspect` | drizzle-kit introspect (draft) | +| `pnpm db:bulk` | Batch-import >50 MB JSON via `scripts/bulk-import-json.ts` | +| `pnpm catalog:sync` | Sync the catalog against a clone source (interactive) | +| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` container | +| `pnpm gamedata:compress` | Pre-compress large gamedata JSON to `.gz` (gzip_static) | +| `pnpm analyze` | Build + report per-route bundle sizes | +| `pnpm performance:report` | Re-render the performance report from a build | +| `pnpm jobs:worker` | Start background task worker | +| `pnpm assets:editor` | Copy TinyMCE editor assets into `public/` | > Replace `pnpm` with `npm run` or `yarn` for other package managers. > @@ -1221,20 +1232,20 @@ the headroom left for everything else on the machine; the effective ceiling is ## Performance Features -| Feature | Description | -| -------------------------------- | ------------------------------------------------------------------------- | -| **React Compiler** | Automatic memoization — reduces unnecessary re-renders | -| **View Transitions API** | Native browser transitions between page navigations | -| **Lenis Smooth Scroll** | Fluid, customizable scrolling | -| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE | -| **Valkey Caching** | Caches API responses up to 30s in Valkey | -| **RCON (TCP Socket)** | Live commands to the emulator (credits, badges, kick, ban) | -| **Streaming & Suspense** | Next.js App Router streaming for fast page loads | -| **Automatic Image Optimization** | `next/image` with Sharp for resizing and WebP/AVIF | -| **Compression** | Gzip compression enabled on all responses | -| **gzip_static (gamedata)** | 48 MB FurnitureData JSON served from pre-built `.gz` — no per-request CPU | -| **PWA** | Service worker with skip-waiting, stale CSS chunk recovery | -| **Biome** | Fast linting and formatting (replaces ESLint + Prettier) | +| Feature | Description | +| ------------------------------ | -------------------------------------------------------------- | +| **React Compiler** | Automatic memoization — reduces unnecessary re-renders | +| **View Transitions API** | Native browser transitions between page navigations | +| **Lenis Smooth Scroll** | Fluid, customizable scrolling | +| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE | +| **Valkey Caching** | Caches API responses up to 30s in Valkey | +| **RCON (TCP Socket)** | Live commands to the emulator (credits, badges, kick, ban) | +| **Streaming & Suspense** | Next.js App Router streaming for fast page loads | +| **Automatic Image Optimization** | `next/image` with Sharp for resizing and WebP/AVIF | +| **Compression** | Gzip compression enabled on all responses | +| **gzip_static (gamedata)** | 48 MB FurnitureData JSON served from pre-built `.gz` — no per-request CPU | +| **PWA** | Service worker with skip-waiting, stale CSS chunk recovery | +| **Biome** | Fast linting and formatting (replaces ESLint + Prettier) | --- @@ -1291,6 +1302,7 @@ The `/client` page loads the Nitro client. Configure the client URL via **Admin ### Radio Supports any streaming radio (e.g., Azuracast). Configure endpoints via CMS Settings: + - `radio_now_playing_api_url` - `radio_listeners_api_url` @@ -1330,6 +1342,7 @@ BYPARR_URL=http://localhost:8191 ### Furniture Import with Auto-Translation The CMS automatically translates furniture names and descriptions to **13 languages** on every import: + - **Native languages** (via official Habbo gamedata): Dutch, English, German, French, Spanish, Turkish, Italian - **Additional languages** (via LibreTranslate Docker at `127.0.0.1:5000`): Portuguese, Finnish, Polish, Russian, Arabic, Japanese diff --git a/deployment/proxy/compose.loopback.yml b/deployment/proxy/compose.loopback.yml index de9c3b3a..7aea4097 100644 --- a/deployment/proxy/compose.loopback.yml +++ b/deployment/proxy/compose.loopback.yml @@ -6,10 +6,4 @@ services: HOSTNAME: 127.0.0.1 PORT: "3002" healthcheck: - test: - [ - "CMD", - "node", - "-e", - "fetch('http://127.0.0.1:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))", - ] + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] diff --git a/docker-compose.yml b/docker-compose.yml index 9aa8748f..b8bab787 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,13 +61,7 @@ x-cms: &cms pids_limit: 512 healthcheck: - test: - [ - "CMD", - "node", - "-e", - "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))", - ] + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"] interval: 15s timeout: 5s retries: 3 @@ -100,4 +94,4 @@ services: interval: 30s timeout: 10s retries: 3 - start_period: 30s + start_period: 30s \ No newline at end of file diff --git a/docs/cms-upgrade-2026-09.md b/docs/cms-upgrade-2026-09.md index 93b215b1..fe96eb84 100644 --- a/docs/cms-upgrade-2026-09.md +++ b/docs/cms-upgrade-2026-09.md @@ -2,20 +2,20 @@ ## Operator changes -| Area | Behavior and location | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Release | Deployment checks HTTP health, release identity and Chromium pages before marking the running image verified. Registry publication reuses that verified digest on the shared runner; independent hosts build and verify their own image. | -| Shared HK | Dialogs scroll within the available viewport. Table column preferences persist per page in the current browser session; filters already remain in the URL. No favorites added. | -| Catalog Studio | Dedicated detail drawer and five separate completeness states: SQL, offers, furnidata, icon and Nitro. Sprite/type conflicts are consistently excluded from import and linked to audit. Missing source files are never represented as available. | -| Operations | Command center includes permission-filtered error groups, personal import failures, open support tickets and news drafts. A failed source is shown separately from an empty source. | -| Error center | Retained occurrence counts, first/last times, identified users, release counts, self-assignment and recognized local links. Assignment requires edit permission and is audited. | -| News | Private server-backed autosave, recover/discard/retry, prior saved revisions restored as a draft, and concurrent-edit detection. New status/search filters and pagination make older drafts reachable. | -| Jobs | Cancellation finishes the current item and stops pending items. Completed work stays completed. Uncertain interrupted mutations are excluded from retry. Live lease checks stop known stale worker writes. | -| Installation | `/admin/devops/installation` shows release, DB latency, Redis, emulator, storage permissions, migration history and worker heartbeat. Renderer defaults are recognized. Registry access is explicitly unverified from the web process. | -| Public dashboard | Current/next published event, clearer unread-message action, useful empty/error states and mobile layout refinements. | -| Audit | Exact actor/action and UTC date filters, readable recorded before/after values and permission-protected CSV of the filtered page, capped at 100 rows. | -| Performance | Active import polling remains 5 seconds; idle polling is 30 seconds and pauses in hidden tabs. History returns at most 30 owned jobs and initially renders 50 items per job. Health probes are deduplicated within a render; diagnostics report observed probe duration. | -| Text | New messages are translated in English, Italian and Dutch. Other locales have explicit English fallback strings. Existing translation debt is not reported as resolved. | +| Area | Behavior and location | +| --- | --- | +| Release | Deployment checks HTTP health, release identity and Chromium pages before marking the running image verified. Registry publication reuses that verified digest on the shared runner; independent hosts build and verify their own image. | +| Shared HK | Dialogs scroll within the available viewport. Table column preferences persist per page in the current browser session; filters already remain in the URL. No favorites added. | +| Catalog Studio | Dedicated detail drawer and five separate completeness states: SQL, offers, furnidata, icon and Nitro. Sprite/type conflicts are consistently excluded from import and linked to audit. Missing source files are never represented as available. | +| Operations | Command center includes permission-filtered error groups, personal import failures, open support tickets and news drafts. A failed source is shown separately from an empty source. | +| Error center | Retained occurrence counts, first/last times, identified users, release counts, self-assignment and recognized local links. Assignment requires edit permission and is audited. | +| News | Private server-backed autosave, recover/discard/retry, prior saved revisions restored as a draft, and concurrent-edit detection. New status/search filters and pagination make older drafts reachable. | +| Jobs | Cancellation finishes the current item and stops pending items. Completed work stays completed. Uncertain interrupted mutations are excluded from retry. Live lease checks stop known stale worker writes. | +| Installation | `/admin/devops/installation` shows release, DB latency, Redis, emulator, storage permissions, migration history and worker heartbeat. Renderer defaults are recognized. Registry access is explicitly unverified from the web process. | +| Public dashboard | Current/next published event, clearer unread-message action, useful empty/error states and mobile layout refinements. | +| Audit | Exact actor/action and UTC date filters, readable recorded before/after values and permission-protected CSV of the filtered page, capped at 100 rows. | +| Performance | Active import polling remains 5 seconds; idle polling is 30 seconds and pauses in hidden tabs. History returns at most 30 owned jobs and initially renders 50 items per job. Health probes are deduplicated within a render; diagnostics report observed probe duration. | +| Text | New messages are translated in English, Italian and Dutch. Other locales have explicit English fallback strings. Existing translation debt is not reported as resolved. | ## Deployment requirements @@ -37,6 +37,7 @@ ## Verification Local verification on 2026-09-09: + - Production build succeeded with fixture configuration and an intentionally unavailable database. Build-time fallback logs are expected in this check. - Full Vitest run: 254 files passed, 4 skipped; 1,466 tests passed, 6 skipped. Coverage thresholds passed (19.89% lines); this does not imply exhaustive coverage. - Global Biome rules/import checks passed across 1,328 files; modified source/locales were formatted separately to avoid unrelated Windows line-ending changes. diff --git a/docs/performance-budgets.md b/docs/performance-budgets.md index 8419ab86..228d61c4 100644 --- a/docs/performance-budgets.md +++ b/docs/performance-budgets.md @@ -14,11 +14,12 @@ For each configured App Router route, resolve its exact app path using `app-path The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every client chunk that route's client-reference manifest lists. This includes layout, page and boundary/loading entries. -The manifest exposes those chunks differently per bundler. Turbopack emits an explicit per-segment `entryJSFiles` map; webpack emits no such field and records chunks only per client module, as `clientModules[*].chunks`, in `[chunkId, fileName, chunkId, fileName, …]` order. The report reads `entryJSFiles` when present and otherwise derives the same envelope from `clientModules`, which is the source Next's own `static-routes-info` uses. Numeric chunk ids are skipped; a malformed chunk _path_ still fails rather than being dropped, so a broken manifest cannot quietly under-report a route. +The manifest exposes those chunks differently per bundler. Turbopack emits an explicit per-segment `entryJSFiles` map; webpack emits no such field and records chunks only per client module, as `clientModules[*].chunks`, in `[chunkId, fileName, chunkId, fileName, …]` order. The report reads `entryJSFiles` when present and otherwise derives the same envelope from `clientModules`, which is the source Next's own `static-routes-info` uses. Numeric chunk ids are skipped; a malformed chunk *path* still fails rather than being dropped, so a broken manifest cannot quietly under-report a route. > The build runs webpack (`next build --webpack`), so the `clientModules` path is the live one. An earlier revision only read `entryJSFiles`, and after the switch to webpack every route reported `unavailable` while the command still exited 0 — the budgets were silently not being measured. When a bundler switch changes the manifest layout again, re-check this section rather than trusting a clean exit. The definition follows the data exposed by the installed Next 16.3.8 build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests. + - Raw bytes are filesystem byte lengths of unique JavaScript assets in that envelope. - Gzip bytes are the **sum of independent gzip level 9 compressions** of those files using the recorded Node/zlib runtime. They are not gzip of concatenated source, nor observed CDN transfer sizes. - Deployment query strings and `/_next/` prefixes are normalized before deduplication. Shared files count once per route; each route is measured independently, with no misleading cross-route total. @@ -33,15 +34,16 @@ Baselines were last recalibrated against a local webpack production build (Next The image build compiles the app, then runs `scripts/performance-report.mjs` in the same `RUN` layer (`Dockerfile:48-53`). In `mode: enforce` a failing route therefore fails the image build, which blocks both the PR `preflight` job and the `deploy` job. Recalibrate the table and `scripts/performance-budgets.json` **together** in the same commit whenever the envelope legitimately grows (new route dependency, Next upgrade). -| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit | -| --------------------- | -----------------: | ------------------: | --------: | ---------: | -| `/me` | 621460 | 195411 | 716800 | 225280 | -| `/news` | 616712 | 193199 | 716800 | 225280 | -| `/events` | 617121 | 193450 | 716800 | 225280 | -| `/search` | 618536 | 194216 | 716800 | 225280 | -| `/admin/catalog` | 1049094 | 330369 | 1208320 | 389120 | -| `/admin/studio/furni` | 1397566 | 447765 | 1607680 | 522240 | +| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit | +| --- | ---: | ---: | ---: | ---: | +| `/me` | 621460 | 195411 | 716800 | 225280 | +| `/news` | 616712 | 193199 | 716800 | 225280 | +| `/events` | 617121 | 193450 | 716800 | 225280 | +| `/search` | 618536 | 194216 | 716800 | 225280 | +| `/admin/catalog` | 1049094 | 330369 | 1208320 | 389120 | +| `/admin/studio/furni` | 1397566 | 447765 | 1607680 | 522240 | Configured limits are positive integer bytes; `null` explicitly means observe-only. `scripts/performance-budgets.json` runs `mode: enforce`: an `over-budget` route prints `Performance budgets enforced: (over-budget)` and exits non-zero. Missing production `BUILD_ID`, unsupported manifests, missing routes or missing referenced assets produce `unavailable` with a reason and **no partial/zero total**; under `enforce` an `unavailable` route also fails the command, so a bundler change that breaks measurement cannot pass quietly. Malformed budget configuration or an unwritable output directory always fails the command. Set `mode` back to `informational` only while recalibrating, never as a permanent state. Synthetic tests cover shared-chunk deduplication, exact byte/gzip calculations, route bootstrap selection, missing data, safe parsing and CLI exit behavior in both modes. Run `pnpm exec vitest run --coverage.enabled=false scripts/performance-report.test.mjs`. + diff --git a/drizzle/drafts/README.md b/drizzle/drafts/README.md index 85781591..607a36d1 100644 --- a/drizzle/drafts/README.md +++ b/drizzle/drafts/README.md @@ -1 +1 @@ -Draft SQL from `pnpm db:generate` (drizzle-kit). Never apply these automatically — copy reviewed statements into drizzle/migrations/ as numbered CMS migrations, then `pnpm db:migrate`. +Draft SQL from `pnpm db:generate` (drizzle-kit). Never apply these automatically — copy reviewed statements into drizzle/migrations/ as numbered CMS migrations, then `pnpm db:migrate`. \ No newline at end of file diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ef0c1946..3673d6d8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,9 +1,9 @@ packages: - - "." + - '.' allowBuilds: - "@parcel/watcher": true - "@swc/core": true + '@parcel/watcher': true + '@swc/core': true cpu-features: true esbuild: true msw: true @@ -13,12 +13,12 @@ allowBuilds: minimumReleaseAge: 60 overrides: - glob: "^11.0.0" - "@esbuild-kit/core-utils": "npm:tsx@^4.23.15" - "@esbuild-kit/esm-loader": "npm:tsx@^4.23.15" - source-map-js: "1.2.2" + glob: '^11.0.0' + '@esbuild-kit/core-utils': 'npm:tsx@^4.23.15' + '@esbuild-kit/esm-loader': 'npm:tsx@^4.23.15' + source-map-js: '1.2.2' # GHSA advisory: postcss-selector-parser <7.1.6. Pulled in only by # @tailwindcss/typography (build-time prose selectors), which pins 6.0.10 # exactly and has no upstream bump. The API subset it uses is unchanged in # v7, so force the patched major. - postcss-selector-parser: "^7.1.6" + postcss-selector-parser: '^7.1.6' diff --git a/scripts/README.md b/scripts/README.md index a5c1c1dc..6e542fff 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -4,31 +4,30 @@ This directory contains utility scripts for managing the EpicNext-Cms project. ## Scripts Overview -| Script | Description | -| :------------------------- | :---------------------------------------------------------------- | -| `dashboard.sh` | Main interactive menu to run all other scripts. | -| `deploy.sh` | Safely redeploys the application by freeing the port first. | -| `logs.sh` | View logs for cms and mariadb services. | -| `backup.sh` | Creates a database backup in `/backups`. | -| `db-restore.sh` | Restores a database backup. | -| `db-optimize.sh` | Optimizes database tables. | -| `docker-prune.sh` | Cleans up unused Docker resources. | -| `verify-deploy.sh` | Checks if the CMS is reachable after deployment. | -| `check-env.sh` | Validates `.env` against `.env.example`. | -| `check-security.sh` | Runs `pnpm audit` and checks for image updates. | -| `monitor.sh` | Shows system disk/container resource usage. | -| `perf-report.sh` | Runs performance profiling. | -| `setup-dev.sh` | Configures development environment. | -| `check-updates.sh` | Checks for package and image updates. | +| Script | Description | +| :--- | :--- | +| `dashboard.sh` | Main interactive menu to run all other scripts. | +| `deploy.sh` | Safely redeploys the application by freeing the port first. | +| `logs.sh` | View logs for cms and mariadb services. | +| `backup.sh` | Creates a database backup in `/backups`. | +| `db-restore.sh` | Restores a database backup. | +| `db-optimize.sh` | Optimizes database tables. | +| `docker-prune.sh` | Cleans up unused Docker resources. | +| `verify-deploy.sh` | Checks if the CMS is reachable after deployment. | +| `check-env.sh` | Validates `.env` against `.env.example`. | +| `check-security.sh` | Runs `pnpm audit` and checks for image updates. | +| `monitor.sh` | Shows system disk/container resource usage. | +| `perf-report.sh` | Runs performance profiling. | +| `setup-dev.sh` | Configures development environment. | +| `check-updates.sh` | Checks for package and image updates. | | `sync-readme-versions.mjs` | Syncs the versions in `README.md` with `package.json` / `.nvmrc`. | -| `maintenance.sh` | Toggles maintenance mode flag. | -| `doctor.sh` | Runs a system diagnostic report. | -| `setup-cron.sh` | Configures automated cron jobs. | -| `alert.sh` | Sends notifications to a configured webhook. | -| `catalog-sync.sh` | Interactive catalog update against a clone source (see below). | +| `maintenance.sh` | Toggles maintenance mode flag. | +| `doctor.sh` | Runs a system diagnostic report. | +| `setup-cron.sh` | Configures automated cron jobs. | +| `alert.sh` | Sends notifications to a configured webhook. | +| `catalog-sync.sh` | Interactive catalog update against a clone source (see below). | ## Usage - Run any script directly, or use the dashboard: ```bash ./scripts/dashboard.sh @@ -69,24 +68,24 @@ menu, so it is usable from cron. Every step the engine exposes is available as a flag: -| Flag | What it does | -| :--------------------------------------------- | :------------------------------------------------------------------------- | -| `--sources=` | Compare against these sources (id or display name) | -| `--icons` | Re-download missing furniture icons | -| `--nitros` | Re-download missing bundles (stored as `.hab`) | -| `--furnidata` | Repair `FurnitureData.json` (add missing, drop duplicates) | -| `--structure` | Repair orphaned / duplicate catalog rows | -| `--check-ids` | Check `FurnitureData.json` for spriteId conflicts | -| `--sql` / `--apply-sql` | Generate / apply the catalog SQL | -| `--organize-sql` | Also emit the `catalog_pages` INSERTs into the SQL file | -| `--import-new` | Import furniture the source has and this hotel lacks | -| `--only=`, `--limit=N`, `--concurrency=N` | Narrow and tune the import | -| `--reconcile` | Align `FurnitureData.json` with `items_base` after importing | -| `--fix-sprite-ids` | Correct `items_base.sprite_id` drift | -| `--rebuild-offers` | Rebuild catalog `offer_id` mirrors | -| `--align-ids` | Move `items_base` ids onto their furnidata ids (report; `--apply` runs it) | -| `--rename-bundles` | Rename legacy `.nitro` bundles to `.hab` (report; `--apply` runs it) | -| `--everything` | Every repair + import step, but still none of the destructive ones | +| Flag | What it does | +| :--- | :--- | +| `--sources=` | Compare against these sources (id or display name) | +| `--icons` | Re-download missing furniture icons | +| `--nitros` | Re-download missing bundles (stored as `.hab`) | +| `--furnidata` | Repair `FurnitureData.json` (add missing, drop duplicates) | +| `--structure` | Repair orphaned / duplicate catalog rows | +| `--check-ids` | Check `FurnitureData.json` for spriteId conflicts | +| `--sql` / `--apply-sql` | Generate / apply the catalog SQL | +| `--organize-sql` | Also emit the `catalog_pages` INSERTs into the SQL file | +| `--import-new` | Import furniture the source has and this hotel lacks | +| `--only=`, `--limit=N`, `--concurrency=N` | Narrow and tune the import | +| `--reconcile` | Align `FurnitureData.json` with `items_base` after importing | +| `--fix-sprite-ids` | Correct `items_base.sprite_id` drift | +| `--rebuild-offers` | Rebuild catalog `offer_id` mirrors | +| `--align-ids` | Move `items_base` ids onto their furnidata ids (report; `--apply` runs it) | +| `--rename-bundles` | Rename legacy `.nitro` bundles to `.hab` (report; `--apply` runs it) | +| `--everything` | Every repair + import step, but still none of the destructive ones | `--import-new` is the step that actually adds furniture: `cloneSingleFurni` downloads the icon and the bundle (sources serve `.nitro`, the local copy is @@ -95,6 +94,7 @@ catalog page plus the offer, exactly like the app's own import. It skips anything already present, so it is safe to re-run. Safety notes: + - **An audit writes nothing** — no repair flag means read-only. - `--dry-run` together with `--import-new` lists what would be imported and imports nothing; use it first on a source before importing thousands of items. @@ -106,7 +106,7 @@ Safety notes: - `--apply-sql` is the only flag that changes the catalog; `--apply` authorises the two heavyweight child scripts (`align-db-ids`, `migrate-nitro-to-hab`). Both only report without it. -- The engine is the same one the admin _Catalog audit_ page streams +- The engine is the same one the admin *Catalog audit* page streams (`runCatalogAudit`), so a terminal run and an in-admin run agree. - `--json` prints one machine-readable report; `--fail-on-errors` exits `3` when error-severity issues remain, which is what a scheduled check should use. diff --git a/src/app/globals.css b/src/app/globals.css index 8f854095..e07e69ce 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2092,9 +2092,9 @@ body:has([data-admin]) { /* Admin forms / boxes follow admin tokens in both light and dark. */ body:has([data-admin]) - input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not( - [type="file"] - ):not([type="color"]), +input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not( + [type="file"] +):not([type="color"]), body:has([data-admin]) select, body:has([data-admin]) textarea { background-color: var(--admin-surface-elevated);