diff --git a/.deploy.lock b/.deploy.lock deleted file mode 100644 index e69de29b..00000000 diff --git a/Dockerfile b/Dockerfile index f7bbb9ad..a7046c3c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -84,8 +84,8 @@ ENV NODE_ENV=production \ HOSTNAME=0.0.0.0 RUN apk add --no-cache tini curl \ && addgroup -g 33 -S nextjs && adduser -u 33 -S -G nextjs nextjs \ - && mkdir -p /app/storage /app/public/nitro-assets /app/public/swf /var/www/Gamedata \ - && chown -R 33:33 /app/storage /app/public /var/www/Gamedata + && mkdir -p /app/storage /app/public/nitro-assets /app/public/swf /var/www/gamedata \ + && chown -R 33:33 /app/storage /app/public /var/www/gamedata COPY --from=builder --chown=nextjs:nextjs /app/public ./public COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static @@ -95,6 +95,6 @@ COPY --chown=nextjs:nextjs scripts/docker-start.mjs ./docker-start.mjs USER nextjs EXPOSE 3002 HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ - 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))"] + CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/cms_api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] ENTRYPOINT ["/sbin/tini", "--"] CMD ["node", "docker-start.mjs"] \ No newline at end of file diff --git a/README.md b/README.md index 58b51bd5..f9915533 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ The CMS ships with a multi-stage Dockerfile that automatically detects your pack - Docker 24+ and Docker Compose v2 - `.env` file configured (see step 3 above) - A MySQL/MariaDB database reachable from the container (`DATABASE_URL` host should point to the DB server, not `127.0.0.1` unless it's reachable from inside the container) -- (Optional) A shared `Gamedata` directory at `/var/www/Gamedata` with write access (see [Volumes](#volumes) below) +- (Optional) A shared `Gamedata` directory at `/var/www/gamedata` with write access (see [Volumes](#volumes) below) ### Quick Start with Docker @@ -175,8 +175,8 @@ pm2 stop next 2>/dev/null || true # 3. Ensure the write directories are owned by www-data (UID/GID 33) so the # container user can write imports/uploads to them. -sudo mkdir -p ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata -sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata +sudo mkdir -p ./public/nitro-assets ./public/swf ./storage /var/www/gamedata +sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage /var/www/gamedata # 4. Build, migrate and start a release tied to the Git commit. # Requires Linux, Bash, Git, flock and Docker Compose v2; no host Node/pnpm. @@ -208,7 +208,7 @@ upstream to your fork/branch if that is where you receive updates. It builds a commit-tagged image and runs migrations in the matching builder container, using the clone's `.env`; no host dependency installation is needed. Only after build and migrations succeed does it recreate the CMS service. It -compares the running image ID, image revision and `/api/health` release with the +compares the running image ID, image revision and `/cms_api/health` release with the expected Git commit. With `CMS_PUBLIC_URL`, it also checks the domain through your proxy/CDN. A healthy response from another release is an error. @@ -252,7 +252,7 @@ the badge directory URL (or a local path). The legacy `NEXT_PUBLIC_IMAGER_URL` a `NEXT_PUBLIC_BADGE_URL` names remain supported at runtime. If the imager points back to the CMS `/imaging` proxy, set `IMAGING_UPSTREAM_URL` to the actual renderer to avoid a loop. With no imager configured, the CMS uses Habbo's public renderer. Client -requests use the existing `/api/imaging/avatar` proxy. Admin badges preserve their +requests use the existing `/cms_api/imaging/avatar` proxy. Admin badges preserve their local `/swf/c_images/album1584` fallback. Public URL resolution uses runtime `APP_URL`. After changing `.env`, recreate the container; an image rebuild is not required. `NEXT_PUBLIC_CMS_RELEASE` deliberately remains compiled into the image. @@ -307,8 +307,8 @@ Container publication is disabled. CI builds, checks and deploys the CMS, but it git rev-parse HEAD docker inspect --format '{{.Image}}' epicnext-cms docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' epicnext-cms -curl -fsS http://127.0.0.1:3002/api/health -curl -fsS https://your-hotel.example/api/health +curl -fsS http://127.0.0.1:3002/cms_api/health +curl -fsS https://your-hotel.example/cms_api/health ``` Both HTTP responses must report the expected `release`. `unknown` means the image @@ -382,9 +382,9 @@ Only the CMS (and the optional avatar imager container, see [Avatar Imaging](#av | `/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) | +| `/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. +**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: @@ -394,11 +394,11 @@ Only the CMS (and the optional avatar imager container, see [Avatar Imaging](#av | 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: +**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: ```bash -sudo mkdir -p ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata -sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata +sudo mkdir -p ./public/nitro-assets ./public/swf ./storage /var/www/gamedata +sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage /var/www/gamedata sudo chmod -R o+rX ./public/nitro-assets ./public/swf ./storage ``` @@ -422,7 +422,7 @@ provides. ### Health Check -The container includes a health check that hits `/api/health` on port 3002 every 30 seconds (and reports DB/Redis/emulator status). Check status with: +The container includes a health check that hits `/cms_api/health` on port 3002 every 30 seconds (and reports DB/Redis/emulator status). Check status with: ```bash docker inspect --format='{{.State.Health.Status}}' epicnext-cms @@ -455,7 +455,7 @@ pnpm's store is hard-linked and its `.bin` shims are symlinks, so **node_modules ```bash # Habbo furnidata (auto-detects roomitemtypes / wallitemtypes / effecttypes) -pnpm db:bulk --file=/var/www/Gamedata/config/FurnitureData.json +pnpm db:bulk --file=/var/www/gamedata/config/FurnitureData.json # Flat array of documents → generic JSON store pnpm db:bulk --file=/data/products.json --table=docs --category=furni @@ -562,7 +562,7 @@ maxmemory-policy allkeys-lru REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2 ``` -Verify via `/api/health` — it should report `"redis": true`. +Verify via `/cms_api/health` — it should report `"redis": true`. --- @@ -618,7 +618,7 @@ server { add_header X-Cache-Status $upstream_cache_status always; } - location /api/ { + location /cms_api/ { proxy_pass http://127.0.0.1:3002; add_header Cache-Control "no-cache, no-store, must-revalidate"; } @@ -704,7 +704,7 @@ A production issue that this section documents: **the `avatar-imaging-pixinode` ### Automatic upstream fallback -Since then the CMS-side avatar proxies (`/api/imaging/avatar` and `/imaging`) are resilient: when the configured upstream (the Polaris container on `8082`) fails or times out, the request is retried against Habbo's public renderer (`https://www.habbo.com/habbo-imaging/avatarimage`), with the `effect` parameter stripped and `img_format` forced to `png`, since the public renderer supports neither. The response is then marked with `X-Imager-Source: primary|fallback` and a shorter `Cache-Control` TTL when served from fallback, so the configured imager is retried soon instead of being masked for hours. No fallback is attempted when the configured upstream already is the Habbo public renderer. As a last line of defence every avatar `` on the site falls back to a grayscale silhouette placeholder instead of a broken-image glyph. +Since then the CMS-side avatar proxies (`/cms_api/imaging/avatar` and `/imaging`) are resilient: when the configured upstream (the Polaris container on `8082`) fails or times out, the request is retried against Habbo's public renderer (`https://www.habbo.com/habbo-imaging/avatarimage`), with the `effect` parameter stripped and `img_format` forced to `png`, since the public renderer supports neither. The response is then marked with `X-Imager-Source: primary|fallback` and a shorter `Cache-Control` TTL when served from fallback, so the configured imager is retried soon instead of being masked for hours. No fallback is attempted when the configured upstream already is the Habbo public renderer. As a last line of defence every avatar `` on the site falls back to a grayscale silhouette placeholder instead of a broken-image glyph. ### Figure string validation @@ -719,7 +719,7 @@ reject — an empty part slot such as the legacy `hr-100-.hd-180-1` default, a path-traversal attempt, an over-long value — is replaced with `DEFAULT_AVATAR_FIGURE`. That stops legacy accounts from rendering broken avatars without weakening the route, which still answers `400` to a malformed -figure (`src/app/api/imaging/avatar/route.ts`). +figure (`src/app/cms_api/imaging/avatar/route.ts`). --- @@ -791,11 +791,11 @@ 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 | +| 1 | `/cms_api/online`, `/cms_api/online/count` | 10s | +| 2 | `/cms_api/photos`, `/cms_api/leaderboard`, `/cms_api/radio/current-dj`, `/cms_api/radio/points/leaderboard` | 60s | +| 3 | `/cms_api/staff`, `/cms_api/teams`, `/cms_api/guilds`, `/cms_api/shop`, `/cms_api/shop/categories`, `/cms_api/values`, `/cms_api/values/categories`, `/cms_api/values/[0-9]+` | 300s | -`/api/radio/shouts` is intentionally excluded: it is live chat. +`/cms_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.** @@ -823,10 +823,10 @@ with a redirect to `/me` and reads `headers()` for a per-request CSP nonce. | `/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` | +| `src/app/cms_api/online/count/stream/route.ts` | `X-Accel-Buffering: no` | +| `src/app/cms_api/radio/stream/route.ts` | `X-Accel-Buffering: no` | +| `src/app/cms_api/admin/import/{audit,furni/route,furni/batch,furni/batch-regen,furni/repair-icons}/route.ts` | `X-Accel-Buffering: no` | +| `src/app/cms_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 | @@ -847,7 +847,7 @@ 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`). - `/api/health` is always exempt so the Docker liveness probe never trips it. + `/cms_api/health` is always exempt so the Docker liveness probe never trips it. 2. **Automatic Cloudflare edge blocks** (optional, works on every Cloudflare plan including Free) — when the gate hard-blocks an IP, the CMS mirrors the block to the zone's **IP Access Rules** (`src/lib/cloudflare-api.ts`), so a @@ -1426,7 +1426,7 @@ EpicNext-CMS maintains strict engineering standards enforced through automated t EpicNext-CMS is engineered for enterprise-grade production environments with zero-downtime deployment capabilities: 1. **Blue/Green Slot Deployments**: - Updates run through containerized slot orchestration (`scripts/docker-update.sh`), ensuring seamless transitions between release versions without dropping requests. - - Automatic health check verification (`/api/health`) validates database connectivity, Redis/Valkey status, and RCON liveness before routing production traffic. + - Automatic health check verification (`/cms_api/health`) validates database connectivity, Redis/Valkey status, and RCON liveness before routing production traffic. 2. **Automated Rollback Protection**: - If a post-cutover health check or startup sequence fails, the system automatically triggers a rollback to the previous stable container image and logs the incident. diff --git a/backups/legacy-delete/items_base_2026-09-06T14-59-53-180Z.json b/backups/legacy-delete/items_base_2026-09-06T14-59-53-180Z.json deleted file mode 100644 index f6ddea20..00000000 --- a/backups/legacy-delete/items_base_2026-09-06T14-59-53-180Z.json +++ /dev/null @@ -1,1684 +0,0 @@ -[ - { - "id": 20060, - "sprite_id": 20060, - "item_name": "DEAL_HC_1", - "public_name": "DEAL_HC_1", - "type": "h", - "width": 0, - "length": 0, - "stack_height": 0, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": "683", - "rare": "0" - }, - { - "id": 20100, - "sprite_id": 20100, - "item_name": "Poker", - "public_name": "", - "type": "i", - "width": 1, - "length": 1, - "stack_height": 1, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": "429", - "rare": "0" - }, - { - "id": 73671, - "sprite_id": 73671, - "item_name": "fun tree", - "public_name": "0", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 0, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 73684, - "sprite_id": 73684, - "item_name": "_pie", - "public_name": "0", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 0, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 190880, - "sprite_id": 190880, - "item_name": "diagon_DoorHp_name", - "public_name": "diagon_DoorHp_name", - "type": "1", - "width": 1, - "length": 0, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 509402, - "sprite_id": 509402, - "item_name": "wf_cnd_habbo_owns_badge", - "public_name": "0", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 0, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 1, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 2926422, - "sprite_id": 2926422, - "item_name": "dicebronze_name", - "public_name": "dicebronze_name", - "type": "S", - "width": 1, - "length": 1, - "stack_height": 0.7, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "dice", - "interaction_modes_count": 6, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 2926423, - "sprite_id": 2926423, - "item_name": "dicecream_name", - "public_name": "dicecream_name", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 0.7, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "dice", - "interaction_modes_count": 6, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 2926424, - "sprite_id": 2926424, - "item_name": "dicegold_name", - "public_name": "dicegold_name", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 0.7, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "dice", - "interaction_modes_count": 6, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 2926425, - "sprite_id": 2926425, - "item_name": "dicesilver_name", - "public_name": "dicesilver_name", - "type": "S", - "width": 1, - "length": 1, - "stack_height": 0.7, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "dice", - "interaction_modes_count": 6, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 13783863, - "sprite_id": 13783863, - "item_name": "habbox_finley_soutien_gorge", - "public_name": "habbox_finley_soutien_gorge_name", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 1, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 13783864, - "sprite_id": 13783864, - "item_name": "habbox_finley_vetements1", - "public_name": "habbox_finley_vetements1_name", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 1, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 13783865, - "sprite_id": 13783865, - "item_name": "habbox_finley_vetements2", - "public_name": "habbox_finley_vetements2_name", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 1, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 13783866, - "sprite_id": 13783866, - "item_name": "habbox_finley_vetements3", - "public_name": "habbox_finley_vetements3_name", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 1, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 13783867, - "sprite_id": 13783867, - "item_name": "habbox_finley_vetements4", - "public_name": "habbox_finley_vetements4_name", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 1, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 13783868, - "sprite_id": 13783868, - "item_name": "habbox_finley_vet_chapeau", - "public_name": "habbox_finley_vet_chapeau_name", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 1, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 22834655, - "sprite_id": 22834655, - "item_name": "icon_39154.png", - "public_name": "icon_39154.png_name", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 1, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 29109645, - "sprite_id": 29109645, - "item_name": "hp_bottle_name", - "public_name": "hp_bottle_name", - "type": "1", - "width": 1, - "length": 1, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": "1299", - "rare": "0" - }, - { - "id": 32610975, - "sprite_id": 32610975, - "item_name": "icon_59613.png", - "public_name": "icon_59613.png_name", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 1, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 36636959, - "sprite_id": 36636959, - "item_name": "dicerosegold_name", - "public_name": "dicerosegold_name", - "type": "S", - "width": 1, - "length": 1, - "stack_height": 0.5, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "dice", - "interaction_modes_count": 6, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 36885449, - "sprite_id": 36885449, - "item_name": "patty3_name", - "public_name": "patty3_name", - "type": "1", - "width": 9, - "length": 5, - "stack_height": 0, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": "6677", - "rare": "0" - }, - { - "id": 46888098, - "sprite_id": 46888098, - "item_name": "abbot_hp_door_name", - "public_name": "abbot_hp_door_name", - "type": "1", - "width": 1, - "length": 0, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 46888099, - "sprite_id": 46888099, - "item_name": "ariana_doorHp_name", - "public_name": "ariana_doorHp_name", - "type": "1", - "width": 1, - "length": 0, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 46888100, - "sprite_id": 46888100, - "item_name": "borisWalldoor_name", - "public_name": "borisWalldoor_name", - "type": "1", - "width": 1, - "length": 0, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 46888116, - "sprite_id": 46888116, - "item_name": "edwardDoor_hp_name", - "public_name": "edwardDoor_hp_name", - "type": "1", - "width": 1, - "length": 0, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 46888117, - "sprite_id": 46888117, - "item_name": "elizabethdoor_name", - "public_name": "elizabethdoor_name", - "type": "1", - "width": 1, - "length": 0, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 46888118, - "sprite_id": 46888118, - "item_name": "fronsacDoorHp_name", - "public_name": "fronsacDoorHp_name", - "type": "1", - "width": 1, - "length": 0, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 46888119, - "sprite_id": 46888119, - "item_name": "georgeDoor_hp_name", - "public_name": "georgeDoor_hp_name", - "type": "1", - "width": 1, - "length": 0, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 46888120, - "sprite_id": 46888120, - "item_name": "google_doorHp_name", - "public_name": "google_doorHp_name", - "type": "1", - "width": 1, - "length": 0, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 46888121, - "sprite_id": 46888121, - "item_name": "kitchens_door_name", - "public_name": "kitchens_door_name", - "type": "1", - "width": 1, - "length": 0, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 46888122, - "sprite_id": 46888122, - "item_name": "percivalPratt_name", - "public_name": "percivalPratt_name", - "type": "1", - "width": 1, - "length": 0, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 46888123, - "sprite_id": 46888123, - "item_name": "quadro_golosa_name", - "public_name": "quadro_golosa_name", - "type": "1", - "width": 1, - "length": 0, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 46888124, - "sprite_id": 46888124, - "item_name": "ravenclawDoor_name", - "public_name": "ravenclawDoor_name", - "type": "1", - "width": 1, - "length": 0, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 46888125, - "sprite_id": 46888125, - "item_name": "signoraGrassa_name", - "public_name": "signoraGrassa_name", - "type": "1", - "width": 1, - "length": 0, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 46888126, - "sprite_id": 46888126, - "item_name": "termeritus_hp_name", - "public_name": "termeritus_hp_name", - "type": "1", - "width": 1, - "length": 0, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 46888127, - "sprite_id": 46888127, - "item_name": "timotyWl_door_name", - "public_name": "timotyWl_door_name", - "type": "1", - "width": 1, - "length": 0, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 71203799, - "sprite_id": 71203799, - "item_name": "ptrnblok1_name", - "public_name": "ptrnblok1_name", - "type": "1", - "width": 1, - "length": 1, - "stack_height": 1, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": "2255", - "rare": "0" - }, - { - "id": 73543675, - "sprite_id": 73543675, - "item_name": "icon_14511.png", - "public_name": "icon_14511.png_name", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 1, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 76961373, - "sprite_id": 76961373, - "item_name": "Unconfirmed 766997.crdownload", - "public_name": "Unconfirmed 766997.crdownload_name", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 1, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 85323276, - "sprite_id": 85323276, - "item_name": "blackBoard_hp_name", - "public_name": "blackBoard_hp_name", - "type": "2", - "width": 1, - "length": 3, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 85323279, - "sprite_id": 85323279, - "item_name": "BwcastleDoor2_name", - "public_name": "BwcastleDoor2_name", - "type": "1", - "width": 1, - "length": 0, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 85323280, - "sprite_id": 85323280, - "item_name": "BW_Arrow_name", - "public_name": "BW_Arrow_name", - "type": "1", - "width": 1, - "length": 0, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 85323281, - "sprite_id": 85323281, - "item_name": "BW_castleDoor_name", - "public_name": "BW_castleDoor_name", - "type": "1", - "width": 1, - "length": 0, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 85323293, - "sprite_id": 85323293, - "item_name": "calderoniHP_name", - "public_name": "calderoniHP_name", - "type": "1", - "width": 1, - "length": 2, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 89266385, - "sprite_id": 89266385, - "item_name": "uni_drag", - "public_name": "0", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 0, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 89311602, - "sprite_id": 89311602, - "item_name": "icon_3850.png", - "public_name": "icon_3850.png_name", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 1, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 92256888, - "sprite_id": 92256888, - "item_name": "habbox_skold_cuisine_tasse", - "public_name": "habbox_skold_cuisine_tasse_name", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 1, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 99910864, - "sprite_id": 99910864, - "item_name": "cama_rosa1_name", - "public_name": "cama_rosa1_name", - "type": "2", - "width": 4, - "length": 2, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "bed", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": "2233", - "rare": "0" - }, - { - "id": 99910865, - "sprite_id": 99910865, - "item_name": "cama_verde1_name", - "public_name": "cama_verde1_name", - "type": "2", - "width": 3, - "length": 2, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "bed", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": "2233", - "rare": "0" - }, - { - "id": 99910866, - "sprite_id": 99910866, - "item_name": "cama_verde2_name", - "public_name": "cama_verde2_name", - "type": "1", - "width": 3, - "length": 1, - "stack_height": 1, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "bed", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": "2233", - "rare": "0" - }, - { - "id": 132663117, - "sprite_id": 132663117, - "item_name": "0", - "public_name": "0", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 0, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 996661664, - "sprite_id": 996661664, - "item_name": "fruitmachin2", - "public_name": "fruitmachin2", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 0, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 1996663036, - "sprite_id": 1996663036, - "item_name": "IcedOutBrache_by_CallMe", - "public_name": "Rare Brache", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 1, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 1996663040, - "sprite_id": 1996663040, - "item_name": "cake", - "public_name": "Rare Cake", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 0, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 2000025635, - "sprite_id": 2000025635, - "item_name": "At Chair Jakeyel", - "public_name": "0", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 0, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "chair", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 2000025636, - "sprite_id": 2000025636, - "item_name": "At Pjake", - "public_name": "0", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 0, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 2000025637, - "sprite_id": 2000025637, - "item_name": "At Table Jakeyel Bigsq", - "public_name": "0", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 0, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "table", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - }, - { - "id": 2000025638, - "sprite_id": 2000025638, - "item_name": "At Tv Finnhairfl", - "public_name": "0", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 0, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "0", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": null, - "rare": "0" - } -] diff --git a/backups/legacy-delete/items_base_second-batch_2026-09-06T15-01-00-161Z.json b/backups/legacy-delete/items_base_second-batch_2026-09-06T15-01-00-161Z.json deleted file mode 100644 index 5b11b69c..00000000 --- a/backups/legacy-delete/items_base_second-batch_2026-09-06T15-01-00-161Z.json +++ /dev/null @@ -1,205 +0,0 @@ -[ - { - "id": 132, - "sprite_id": 132, - "item_name": "floortile", - "public_name": "Floor Tile", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 0, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 1, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": "429", - "rare": "0" - }, - { - "id": 420, - "sprite_id": 420, - "item_name": "soft_jaggara_norja", - "public_name": "Norja-pehmojakkara", - "type": "s", - "width": 1, - "length": 3, - "stack_height": 1.7, - "allow_stack": 1, - "allow_sit": 1, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 1, - "vending_ids": "0", - "multiheight": "", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": "429", - "rare": "0" - }, - { - "id": 1001, - "sprite_id": 1001, - "item_name": "Chess", - "public_name": "", - "type": "i", - "width": 1, - "length": 1, - "stack_height": 1, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": "429", - "rare": "0" - }, - { - "id": 1011, - "sprite_id": 1011, - "item_name": "TicTacToe", - "public_name": "", - "type": "i", - "width": 1, - "length": 1, - "stack_height": 1, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": "429", - "rare": "0" - }, - { - "id": 1021, - "sprite_id": 1021, - "item_name": "BattleShip", - "public_name": "", - "type": "i", - "width": 1, - "length": 1, - "stack_height": 1, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": "429", - "rare": "0" - }, - { - "id": 1659, - "sprite_id": 1659, - "item_name": "ticket", - "public_name": "Big Ticket Bundle", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 1, - "allow_stack": 1, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": "429", - "rare": "0" - }, - { - "id": 10056, - "sprite_id": 10056, - "item_name": "Vacuum", - "public_name": "laundry_r18_vacuum", - "type": "s", - "width": 1, - "length": 1, - "stack_height": 0, - "allow_stack": 0, - "allow_sit": 0, - "allow_lay": 0, - "allow_walk": 0, - "allow_gift": 1, - "allow_trade": 1, - "allow_recycle": 0, - "allow_marketplace_sell": 0, - "allow_inventory_stack": 1, - "interaction_type": "default", - "interaction_modes_count": 0, - "vending_ids": "0", - "multiheight": "", - "customparams": "", - "effect_id_male": 0, - "effect_id_female": 0, - "clothing_on_walk": "", - "page_id": "9966", - "rare": "0" - } -] diff --git a/deployment/proxy/compose.loopback.yml b/deployment/proxy/compose.loopback.yml index de9c3b3a..f9811a65 100644 --- a/deployment/proxy/compose.loopback.yml +++ b/deployment/proxy/compose.loopback.yml @@ -11,5 +11,5 @@ services: "CMD", "node", "-e", - "fetch('http://127.0.0.1:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))", + "fetch('http://127.0.0.1:3002/cms_api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))", ] diff --git a/deployment/proxy/nginx-cms.conf b/deployment/proxy/nginx-cms.conf index 286acc0d..69655d97 100644 --- a/deployment/proxy/nginx-cms.conf +++ b/deployment/proxy/nginx-cms.conf @@ -62,11 +62,11 @@ map "$cms_sess_cookie$cms_authz_header" $cms_skip_cache { map $uri $cms_cc_class { default 0; # online count wordt door elke pagina en de SSE-stream gepolld - ~^/api/online(/count)?$ 1; + ~^/cms_api/online(/count)?$ 1; # snel verouderende, maar publieke lijsten - ~^/api/(photos|leaderboard|radio/current-dj|radio/points/leaderboard)$ 2; + ~^/cms_api/(photos|leaderboard|radio/current-dj|radio/points/leaderboard)$ 2; # stabiele catalogus- en rosterdata - ~^/api/(staff|teams|guilds|shop|values)(/categories|/[0-9]+)?$ 3; + ~^/cms_api/(staff|teams|guilds|shop|values)(/categories|/[0-9]+)?$ 3; } # Eén bron van waarheid: klasse + al dan niet ingelogd. De `|`-scheiding is @@ -257,17 +257,17 @@ server { # die niet bestaan. Serveer de bronbestanden daar ook, zodat een verouderde # browsercache geen 404's meer veroorzaakt. location = /nitro-client/nitro-assets/gamedata/SoundData.json { - alias /var/www/Gamedata/config/SoundData.json; + alias /var/www/gamedata/config/SoundData.json; add_header Cache-Control "public, max-age=2592000" always; access_log off; } location = /nitro-client/configuration/soundboard-layout.jsonc { - alias /var/www/Gamedata/config/soundboard-layout.jsonc; + alias /var/www/gamedata/config/soundboard-layout.jsonc; add_header Cache-Control "public, max-age=2592000" always; access_log off; } location = /nitro-client/configuration/soundboard-sounds.jsonc { - alias /var/www/Gamedata/config/soundboard-sounds.jsonc; + alias /var/www/gamedata/config/soundboard-sounds.jsonc; add_header Cache-Control "public, max-age=2592000" always; access_log off; } @@ -331,7 +331,7 @@ server { # 4. alles wat overblijft (c_images, album*, clothes, …) — content-addressed # of per item uniek, nooit herschreven onder dezelfde naam. Blijft lang. location ^~ /gamedata/config/ { - alias /var/www/Gamedata/config/; + alias /var/www/gamedata/config/; add_header Cache-Control "public, max-age=300, must-revalidate"; access_log off; add_header Cache-Tag "cms-gamedata"; @@ -347,7 +347,7 @@ server { } location ^~ /gamedata/bundled/ { - alias /var/www/Gamedata/bundled/; + alias /var/www/gamedata/bundled/; add_header Cache-Control "public, max-age=3600, must-revalidate"; access_log off; add_header Cache-Tag "cms-gamedata"; @@ -363,7 +363,7 @@ server { } location ^~ /gamedata/icons/ { - alias /var/www/Gamedata/icons/; + alias /var/www/gamedata/icons/; add_header Cache-Control "public, max-age=3600, must-revalidate"; access_log off; add_header Cache-Tag "cms-gamedata"; @@ -383,7 +383,7 @@ server { } location /gamedata/ { - alias /var/www/Gamedata/; + alias /var/www/gamedata/; add_header Cache-Control "public, max-age=604800"; access_log off; add_header Cache-Tag "cms-gamedata"; @@ -426,7 +426,7 @@ server { # ─── Furni-icons: gamedata-boom eerst, CMS-boom als valrimpeltje ─── # # De CMS bouwde zijn icon-URL's altijd vanaf /swf/dcr/hof_furni/icons/, - # maar de catalogus komt uit /var/www/Gamedata/icons: + # maar de catalogus komt uit /var/www/gamedata/icons: # - van de 16.263 classnames in items_base staat er 15.338 hier onder de # "veilige" naam (`highscore_perteam_1_icon.png`), tegen 11.267 in # public/swf/dcr/hof_furni/icons; @@ -447,7 +447,7 @@ server { # hieronder neemt Cloudflare de zone-TTL (1 jaar) over. Zelfde les als bij # @gamedata_missing hierboven. location ^~ /swf/dcr/hof_furni/icons/ { - alias /var/www/Gamedata/icons/; + alias /var/www/gamedata/icons/; error_page 404 = @furni_icon_from_cms; # 403 = het pad valt op een map, dus `alias` eindigt op een directory. # Dat is geen icon, dus dezelfde route als een miss. @@ -552,7 +552,7 @@ server { } # ─── API Proxy's ─── - location /api/auth/ { + location /cms_api/auth/ { proxy_pass http://cms_app; proxy_http_version 1.1; proxy_set_header Host $host; @@ -575,7 +575,7 @@ server { # body zelf wordt NIET tussen-gecachet (geen proxy_cache_*): stampede- # bescherming doet src/lib/cache.ts (in-process single-flight + Redis). # De header zet de TTL voor browser + CDN (10/60/300s + SWR). - location ~ ^/api/(?:staff|teams|guilds|photos|leaderboard|online|online/count|shop|shop/categories|values|values/categories|values/[0-9]+|radio/current-dj|radio/points/leaderboard)$ { + location ~ ^/cms_api/(?:staff|teams|guilds|photos|leaderboard|online|online/count|shop|shop/categories|values|values/categories|values/[0-9]+|radio/current-dj|radio/points/leaderboard)$ { proxy_pass http://cms_app; proxy_http_version 1.1; proxy_set_header Host $host; @@ -603,7 +603,7 @@ server { # 3. send_timeout — de server-level 10s meet de pauze tussen twee writes. # Een stream die 25s pingt, of een batch die minuten niets doet, wordt # daar dus losgekapt. Daarom hier een eigen, ruime waarde. - location ~ ^/api/(?:online/count/stream|radio/stream|admin/import/.*|admin/studio/nitro-cleanup.*)$ { + location ~ ^/cms_api/(?:online/count/stream|radio/stream|admin/import/.*|admin/studio/nitro-cleanup.*)$ { proxy_pass http://cms_app; proxy_http_version 1.1; proxy_set_header Host $host; @@ -627,7 +627,7 @@ server { add_header X-Accel-Buffering "no" always; } - location /api/badges/custom { + location /cms_api/badges/custom { proxy_pass http://127.0.0.1:2096; proxy_http_version 1.1; proxy_set_header Host $host; @@ -645,7 +645,7 @@ server { # ─── Imaging & media: de app levert de eigen Cache-Control ─── # De catch-all hieronder forceert no-store; avatars en uploads zijn # onveranderlijk per sleutel en moeten door de browser gecachet worden. - location /api/imaging/ { + location /cms_api/imaging/ { proxy_pass http://cms_app; proxy_http_version 1.1; proxy_set_header Host $host; @@ -657,7 +657,7 @@ server { proxy_read_timeout 30s; } - location /api/media/ { + location /cms_api/media/ { proxy_pass http://cms_app; proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/docker-compose.yml b/docker-compose.yml index ddf9f6c0..f8f3fe7c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,7 +52,7 @@ x-cms: &cms - ./public/nitro-assets:/app/public/nitro-assets - ./public/swf:/app/public/swf - ./storage:/app/storage - - /var/www/Gamedata:/var/www/Gamedata + - /var/www/gamedata:/var/www/gamedata # ── Resource limits ── mem_limit: 4g @@ -64,7 +64,7 @@ x-cms: &cms "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))", + "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/cms_api/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))", ] interval: 15s timeout: 5s diff --git a/docs/CATALOG_REFACTOR_PLAN.md b/docs/CATALOG_REFACTOR_PLAN.md deleted file mode 100644 index 5559044b..00000000 --- a/docs/CATALOG_REFACTOR_PLAN.md +++ /dev/null @@ -1,118 +0,0 @@ -# Refactor del catalogo HK — analisi e programma - -Data: 6 settembre 2026. Base verificata: main, commit 9b0ea2fb. Documento di proposta, non implementazione approvata. Il sito /admin/catalog reindirizza al login senza sessione staff: nessuna prova delle mutazioni sul database di produzione. Le criticità indicate sono percorsi verificati nel codice; gli effetti concorrenti richiedono riproduzione controllata. - -## Obiettivo e perimetro - -Un catalogo HK con un solo ambiente di lavoro, regole coerenti e operazioni recuperabili. Conservare stile HK, icone reali, salvataggio diretto, catalogo normale e Builder Club. Nessun ritorno dei Preferiti. - -Inclusi: albero, categorie, offerte, prezzi, bundle, disponibilità, proprietà condivise dei furni, traduzioni, ricerca, anteprima, operazioni massive, manutenzione, collegamenti a Catalog Studio e sincronizzazione Git/hotel. - -Catalog Studio conserva la responsabilità di importare, convertire e riparare .nitro, icone e furnidata. Il refactor collega questi strumenti alla selezione del catalogo; non comporta riscrivere il convertitore, cambiare protocollo dell'emulatore o sostituire il sistema Git/Gitea già esistente. - -## Inventario verificato - -Sono già presenti virtualizzazione dell'albero, trascinamento, multiselezione, griglia/tabella, editor dei prezzi, anteprima negozio, import massivo, traduzioni, manutenzione e coda di export Git. Vanno riutilizzati. - -| File | Righe attuali | Responsabilità da separare | -|---|---:|---| -| src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx | 2342 | Rendering, selezione, editor offerta/furno, prezzi massivi, drag and drop, dialoghi | -| src/components/admin/catalog-manager/sortable-tree.tsx | 1135 | Lettura albero, mutazioni, ricerca, trascinamento, comandi | -| src/components/admin/catalog-manager/inline-editor.tsx | 775 | Fetch, stato modifiche, schede, salvataggio, anteprima | -| src/app/admin/catalog/[id]/catalog-page-form.tsx | 738 | Campi, layout, media, salvataggio | -| src/app/admin/catalog/[id]/catalog-translate-tab.tsx | 617 | Selezione, proposta traduzioni, applicazione | -| src/app/admin/catalog/builder-club/bc-manager.tsx | 608 | Gestione parallela BC | -| src/app/admin/catalog/page.tsx | 526 | Query, conteggi, varianti normale/BC, composizione UI | - -Le dimensioni aiutano a trovare i punti di intervento: l'obiettivo non è un limite arbitrario di righe, ma responsabilità verificabili e riutilizzabili. - -## Problemi e interventi - -| Priorità | Evidenza | Intervento | -|---|---|---| -| P0 | sortable-tree.tsx invia il riordino dei fratelli con Promise.allSettled, una action per riga; catalog.ts aggiorna RCON per ciascuna | Un comando batch con lista completa, validazione, transazione e un solo evento di aggiornamento | -| P0 | catalog-items.ts riordina le offerte con update sequenziali fuori transazione | Stesso contratto atomico per l'ordine delle offerte | -| P0 | updateCatalogPage accetta parentId direttamente; il controllo cicli è separato in movePage | Validazione comune per creazione, form, spostamento e API; controllare destinazioni inesistenti e concorrenza | -| P0 | deletePage normale sposta figli, elimina offerte e pagina separatamente | Transazione, analisi dell'impatto e snapshot ripristinabile | -| P0 | updateCatalogItem modifica pagina, offerta e items_base con scritture separate | Transazione e verifica che il furno appartenga all'offerta; scope distinto per proprietà condivise | -| P1 | cascadeDelete non mantiene un insieme di nodi visitati; il calcolo profondità BC è ricorsivo senza guardia ai cicli | Lettura tollerante di dati incoerenti, diagnostica e arresto sicuro delle traversate | -| P1 | handleEditTab modifica lo stato prima della conferma; chiusura X bypassa la protezione | Un unico controllo delle modifiche per cambio pagina, offerta, scheda, uscita e navigazione | -| P1 | loadPage/loadItemsData non annullano o identificano la richiesta precedente | AbortController e identità della selezione; solo la risposta corrente può aggiornare l'editor | -| P1 | Il salvataggio ignora il booleano restituito da RCON; Git opera in coda | Distinguere DB salvato, invio hotel riuscito/fallito e stato Git; retry senza risalvare i dati | -| P1 | loadCatalogItemsData usa Number(value) || fallback per order_number, offer_id e amount | Definire semantica di zero/null per campo e testare il round trip prima di cambiare i fallback | -| P2 | Tutte le offerte e metadati sono caricati insieme; filtro con CAST(page_id AS CHAR) | Misurare query/payload, separare elenco e dettagli, paginazione e adapter compatibile INT/VARCHAR | -| P2 | Editor normale/BC e form condividono solo parte delle regole; testi anche letterali | Contratti comuni, differenze BC esplicite, traduzioni e permessi coerenti | - -Riferimenti principali: src/actions/catalog.ts, src/actions/catalog-items.ts, src/actions/catalog-bc.ts, src/lib/services/catalog-tree.ts, src/lib/services/catalog-items-loader.ts, src/app/api/admin/catalog/tree/route.ts, src/components/admin/catalog-manager/catalog-manager-dialog.tsx, src/components/admin/catalog-manager/inline-editor.tsx. - -## Alternative - -1. **Pulizia dei file mantenendo tutti gli editor:** rischio iniziale basso, ma conserva duplicazioni e differenze operative. Utile solo come passaggio iniziale. -2. **Refactor progressivo con un editor principale — consigliato:** servizi comuni prima, poi promozione del Visual Manager a pagina. Permette piccoli rilasci e confronti tra vecchio e nuovo percorso. -3. **Riscrittura completa:** libertà maggiore, ma più rischio di perdere casi speciali, compatibilità DB e funzioni già presenti. Non giustificata dall'inventario attuale. - -## Architettura proposta - -Modulo src/features/catalog con confini chiari: - -- domain/: tipi Page, Offer, FurnitureReference, CatalogKind; validazione gerarchie, prezzi, bundle e disponibilità; nessuna dipendenza React/DB. -- server/queries/: letture albero, elenco offerte, dettaglio e ricerca; output serializzabile esplicito. -- server/commands/: create/update/move/reorder/delete; autorizzazione, validazione, transazioni, controllo revisione e audit. -- server/repositories/: accesso Drizzle e compatibilità delle colonne; adapter normale/BC senza fingere che tutti i campi coincidano. -- client/: stato selezione, modifiche locali, operazioni in corso, caricamento e gestione conflitti. -- components/: albero, elenco offerte, editor categoria, editor offerta, dettagli furno, diagnostica, stato sincronizzazione. - -Le route e le action attuali rimangono inizialmente adapter sottili. Un unico risultato di operazione include ID operazione, revisione, elementi modificati, eventuali errori di campo e stato sincronizzazione. Non introdurre nuove librerie prima di verificare i limiti degli strumenti già installati. - -Flusso di scrittura: permesso → validazione → verifica revisione → transazione DB con audit → risposta di salvataggio → aggiornamento hotel/export Git. La durabilità del passaggio DB→coda va garantita con un evento persistito nella transazione o meccanismo equivalente verificato. Un fallimento Git/RCON non deve far ripetere una creazione già committata. Riutilizzare il worker e la coda esistenti, aggiungendo idempotenza dove manca. - -## UX proposta - -Pagina /admin/catalog con barra: Normale/BC, ricerca, nuova categoria, aggiungi furni, stato operazioni. Sotto: categorie a sinistra, offerte al centro, dettagli a destra. Il pannello dettagli si richiude; su schermi piccoli diventa una vista dedicata. Un solo scorrimento per ciascuna area, azioni di salvataggio sempre raggiungibili. - -La URL conserva catalogo, categoria, offerta, vista e ricerca; i campi non salvati restano nello stato locale. Indietro/avanti e ricaricamento devono riaprire il contesto corretto. I vecchi URL dei dettagli continuano a funzionare. - -Tre oggetti riconoscibili: - -- Categoria: percorso, titolo, icona, layout, visibilità e requisiti. -- Offerta: prezzo, valuta, quantità, componenti bundle, disponibilità e ordine. -- Furno condiviso: classname, sprite, dimensioni e interazioni; mostrare quante offerte lo referenziano prima di una modifica globale. - -Idee operative: - -- Ricerca trasversale per nome, classname, ID pagina/offerta/furno e sprite ID, con percorso nei risultati. -- Selettore visuale di categoria e layout; proprietà tecniche nelle Avanzate. -- Prezzi con icone reali delle valute; mostrare il prima/dopo delle operazioni massive, arrotondamenti ed elementi esclusi. -- Multiselezione con riepilogo di spostamento/eliminazione; dopo un errore mantenere selezionati i falliti. -- Anteprima del negozio già esistente integrata nel contesto; non presentarla come prova completa del comportamento del client hotel. -- Diagnostica su richiesta: offerta, SQL, furnidata, Nitro e icona separati. Collegamento a Catalog Studio sul furno esatto; nessuna scansione pesante a ogni apertura. -- Storico di chi/cosa/quando con differenze e ripristino. Il ripristino controlla revisioni successive: non sovrascrive in silenzio modifiche di altri operatori e non annulla acquisti già avvenuti. -- Riepilogo visibile: salvato, invio hotel, Git. Gli errori hanno riferimento al monitor CMS. -- Stati vuoti, errori, caricamento e sola lettura distinti; traduzioni complete e uso da tastiera. - -## Programma di lavoro e criteri di uscita - -| Lotto | Consegna | Criterio per proseguire | -|---|---|---| -| 1. Baseline | Matrice funzioni/route/permessi normale e BC; fixture con bundle, LTD, offerte speciali, zeri/null, alberi incoerenti; misure query e rete | Tutti i flussi esistenti hanno una destinazione nel piano, senza omissioni | -| 2. Integrità | Validatori, transazioni di riordino/spostamento/eliminazione, gerarchie sicure, revisioni | Un fallimento intermedio non lascia dati parziali; due operatori non si sovrascrivono | -| 3. Servizi condivisi | Query/command/repository e risultato comune; vecchie route come adapter | Vecchie UI superano le stesse prove con il nuovo backend | -| 4. Stato editor | Unica gestione delle modifiche, richieste annullabili, risposta coerente con selezione | Annullare l'uscita conserva tutto; cambi rapidi mostrano sempre l'ultima selezione | -| 5. Pagina unificata | Visual Manager nella pagina, griglia/tabella condivise, URL, layout adattivo | Parità normale/BC e vecchi link conservati; niente perdita di scroll o azioni nascoste | -| 6. Operazioni avanzate | Ricerca, editor bundle, prezzi massivi con differenze, storico e diagnosi contestuale | Gli effetti sono spiegati prima dell'applicazione; retry applica solo ciò che manca | -| 7. Sincronizzazione | Stato DB/hotel/Git, operazioni persistenti, retry/idempotenza | Guasto dopo commit e riavvio worker non duplicano né perdono l'operazione | -| 8. Prestazioni e rimozione duplicati | Paginazione, caricamento progressivo, accessibilità, eliminazione vecchi componenti | Confronto misurato e prove finali; nessuna route o funzione rimasta senza equivalente | - -I lotti 2 e 7 condividono il contratto delle operazioni: progettare subito evento persistente e idempotenza, anche se la UI di stato arriva dopo. Nessuna stima in giorni finché non sono note dimensioni reali del catalogo, varianti DB e casi speciali attivi. Ogni lotto può richiedere più PR piccole; niente sostituzione monolitica. - -## Verifica e rilascio - -Test unitari delle regole; integrazione su MariaDB per rollback, concorrenza e varianti INT/VARCHAR; browser con permessi lettura/modifica, desktop e schermo ridotto. Simulare doppio submit, timeout, risposta fuori ordine, fallimento RCON, Git non raggiungibile, riavvio dopo commit. Conservare test e componenti esistenti finché la parità non è dimostrata. - -Registrare baseline e risultati per categorie grandi/piccole: richieste per riordino, tempo DB, payload, tempo fino a editor utilizzabile, risposte fallite. Non promettere percentuali senza dati. - -Rilascio progressivo con selezione reversibile del nuovo editor. Il ritorno alla UI precedente deve usare gli stessi servizi corretti. Migrazioni additive e compatibili; il rollback dell'app non deve richiedere la cancellazione di dati. Eliminare le vecchie UI solo dopo parità verificata. Confermare CI, deploy, health e prove staff prima di dichiarare risolto il flusso live. - -## Primo passo consigliato - -Lotti 1 e 2: inventario di compatibilità e correzione delle operazioni a rischio, mantenendo inizialmente l'aspetto corrente. Poi estrarre i servizi e unificare l'editor. È la sequenza che permette di migliorare UX senza portare avanti gli stessi difetti dentro una nuova schermata. diff --git a/docs/operations/backup-and-restore-drill.md b/docs/operations/backup-and-restore-drill.md deleted file mode 100644 index eb3dd8b7..00000000 --- a/docs/operations/backup-and-restore-drill.md +++ /dev/null @@ -1,93 +0,0 @@ -# Backup and isolated restore drill - -This opt-in operator tool creates one MariaDB logical dump and copies explicitly selected persistent files. It never runs during install, update or CI deployment. The only restore operation is a **disposable drill**: there is no production restore command, database target, destination directory or overwrite option. - -## Scope and prerequisites - -Use the existing project Node toolchain and Docker CLI/Engine on a Linux host. Creation uses a short-lived `mariadb:11.4.5` client on the Docker host network, so `127.0.0.1` means that host. Use a local Docker Engine/context with the same filesystem; remote Docker daemons and Docker Desktop are not supported for creation. The image must already be available or downloadable through the operator's normal image policy. No packages are installed by this tool. - -Choose the single application database explicitly. Its tables must use InnoDB; empty databases, system schemas and unsupported engines are rejected. The backup account needs access to every application table, view, trigger, routine and event being exported. Account/grant provisioning belongs to the operator; the tool does not change privileges. Restore compatibility is checked with the pinned MariaDB image, not guaranteed across arbitrary server versions, plugins, collations or external schema dependencies. - -Before starting, pause **every database/file writer** and schema changer for the whole creation command: CMS requests that write, workers, schedulers, emulator processes, MariaDB events, import jobs and other tools using these resources. Keep them paused until the command exits. `--writers-quiesced` records your acknowledgement; it does not stop services or prove that they are stopped. No DDL may run while dumping. InnoDB's transaction snapshot alone cannot make independently copied files consistent with rows or coordinate other services. The before/after table inventory and second source-file hash pass detect many concurrent changes, but cannot replace quiescing. - -The dump explicitly uses `--single-transaction --quick --skip-lock-tables --routines --events --triggers --hex-blob --tz-utc`. It retains schema/data and named SQL objects while streaming rows. See [MariaDB dump snapshot and object options](https://mariadb.com/docs/server/clients-and-utilities/backup-restore-and-import-clients/mariadb-dump). This is a logical application backup, not point-in-time recovery: binlogs, server accounts/grants, server configuration, Redis state and unrelated databases are outside its scope. - -## Private configuration - -Create a private JSON file **outside the clone and every selected source directory**, for example `/etc/epicnext/backup.json`. Use a directory accessible only to the operator and file mode `0600`. Edit it with the host's normal private configuration workflow; do not put a password in a shell command or commit the file. - -```json -{ - "database": { - "host": "127.0.0.1", - "port": 3306, - "user": "REPLACE_WITH_BACKUP_ACCOUNT", - "password": "REPLACE_PRIVATELY", - "database": "REPLACE_WITH_APPLICATION_DATABASE" - }, - "roots": { - "storage": "/srv/epicnext/storage", - "nitro": "/srv/epicnext/public/nitro-assets", - "swf": "/srv/epicnext/public/swf", - "gamedata": "/var/www/Gamedata" - } -} -``` - -Replace the example clone path and database settings. `storage`, `nitro` and `swf` are required existing directories, including when empty. `gamedata` is optional; omit its key only when those files are independently backed up or not used. All paths must be absolute, distinct, non-overlapping and free of `..` and symlink/junction components. Links, hardlinked files, special files and secret configuration filenames such as `.env`, `.docker-install` and `persistent.path` inside a selected root cause rejection. The configuration itself may not be inside any source root. Keep writers and directory ownership controlled for the duration; this is not a filesystem snapshot resistant to hostile concurrent renames. - -The tool reads only this explicit JSON. It does not source `.env`, inspect a running application's environment or inherit its secrets into Docker. The MariaDB password is written to a random private temporary directory/file (`0700`/`0600`) and read through a read-only container mount with `--defaults-file` as the first client option. It is absent from process arguments and tool logs; source configuration and absolute source paths are absent from the manifest. Temporary credentials are removed on ordinary success/failure. See [MariaDB option-file handling](https://mariadb.com/docs/server/clients-and-utilities/backup-restore-and-import-clients/mariadb-dump#defaults-file-name). - -Only Docker connection/runtime environment variables are passed to child processes. Do not point `DOCKER_HOST`/`DOCKER_CONTEXT` at another host or enable shell tracing around private configuration work. - -## Create and verify - -Provision a private backup parent directory, with adequate free space, outside all source roots. Choose a **new** absolute artifact directory for each run. After pausing the writers described above, run from the clone root: - -```sh -node scripts/backup/cli.mjs create \ - --config /etc/epicnext/backup.json \ - --output /srv/epicnext-backups/2026-09-13T200000Z \ - --writers-quiesced -``` - -The date is an example; use a new name for the actual run. Existing directories are refused, including earlier incomplete attempts. A successful artifact contains: - -```text -manifest.json - database.sql - files/storage/... - files/nitro/... - files/swf/... - files/gamedata/... (only when selected) -``` - -`manifest.json` is written last and marks the format complete. It records each relative file path, byte count and SHA-256, empty directories, selected logical roots, creation time and database inventory. The inventory includes table row counts and MariaDB extended table checksums plus names/types of views, triggers, routines and events. These checksums validate restored table contents; they are not cryptographic signatures or a substitute for application-level checks. See [MariaDB CHECKSUM TABLE semantics and version limits](https://mariadb.com/docs/server/reference/sql-statements/table-statements/checksum-table). - -On a caught failure the newly created artifact is removed; an interrupted process can leave an incomplete directory, which verification refuses. Source roots and existing backup directories are never overwritten. Files are copied and hashed as streams, then source hashes are checked again across the dump interval. This performs multiple full reads of the assets and table data; allow sufficient time and disk capacity during the maintenance window. - -After creation you may resume writers. Copy the completed artifact to the designated protected backup location according to the operator's retention/encryption policy. SQL and uploaded files contain application data and may themselves contain sensitive values. Hashes detect corruption against the manifest, not an attacker who can replace both. Keep the manifest and artifact under trusted access control. Secrets, TLS material and deployment configuration excluded from this artifact need their separate recovery procedure. - -## Isolated restore drill - -Run the drill against a trusted completed artifact: - -```sh -node scripts/backup/cli.mjs drill \ - --artifact /srv/epicnext-backups/2026-09-13T200000Z -``` - -The drill first rejects missing, extra, changed or unsafe paths/files. It copies the persistent files into a new private temporary directory and verifies their contents. It creates a randomly named MariaDB container with **no network and no published ports**, a fresh password supplied by file, and a disposable database volume. SQL is imported through the container's standard input; there is no connection to the source database. The event scheduler stays off. The resulting table counts/checksums and object inventory must match the manifest. This proves dump importability and the recorded contents, not a full CMS/emulator startup or external-service recovery. - -On ordinary completion/failure it removes the container, its anonymous volume and temporary files. Cleanup failure makes the drill fail. A host crash or forced process termination can interrupt cleanup; inspect only resources labeled `cms.backup-drill=true` and private `cms-backup-private-*` temporary directories from that run, and review their ownership before manual removal. Never substitute an existing database/container into this procedure. - -A real production recovery remains a separate, reviewed procedure with its own deployment configuration, credentials, downtime and application checks. This tool deliberately cannot perform it. - -## Repository verification - -```sh -pnpm exec vitest run --coverage.enabled=false scripts/backup -pnpm exec vitest run --config vitest.integration.config.ts integration/backup.test.ts -``` - -The local suite exercises real file copies, empty directories, SQL/file tampering, manifest traversal, links, secret-file rejection, overwrites, cleanup and changes during backup. The integration suite requires Docker: failure to start MariaDB fails the suite. It uses a real database with Unicode text, large unsigned identifiers, a foreign key, view, trigger, procedure and event; it creates an artifact, restores it to a second disposable server and checks both byte corruption and a SQL content change with a recomputed file hash. A passing local file suite alone is not evidence that the MariaDB drill ran. diff --git a/docs/operations/ci-preflight.md b/docs/operations/ci-preflight.md deleted file mode 100644 index 18fc5263..00000000 --- a/docs/operations/ci-preflight.md +++ /dev/null @@ -1,17 +0,0 @@ -# Docker and news checks before merging - -Push work to a `codex/**` branch and open a pull request targeting `main` or `master`. CI runs the existing `check` job first. After it passes, the new `preflight` job builds the production Dockerfile and runs the isolated news browser suite against that exact image. Review both results before merging; repository branch protection can require `check` and `preflight` for pull requests. - -Each execution uses `epicnext-cms:preflight--`, including retries and separate push/PR runs of the same commit. The full checked-out commit is passed as `NEXT_DEPLOYMENT_ID` and `NEWS_E2E_RELEASE`; `NEWS_E2E_IMAGE` identifies that execution's image. Failures in dependency/browser setup, Docker build, news tests or cleanup fail the job. News browser artifacts are uploaded even when the gate fails. - -The script installs dependencies with the frozen lockfile and installs Chromium on the CI runner. The real Docker build uses the existing Dockerfile's fixture build settings. It never copies or sources a deployment `.env`, connects to a VPS, runs live migrations, updates live containers, publishes a registry image or changes release tags. The existing isolated news runner owns its disposable MariaDB, Redis and application containers. Cleanup removes only the preflight tag and its empty private temporary directory; it does not prune Docker resources. - -The `deploy` and `publish-container` conditions remain restricted to pushes on `main`/`master`. Deployment still runs its own news gate before live migrations/cutover. A successful branch preflight supplies earlier evidence; the deployed commit is independently checked again. - -On a Linux development or CI host with the project toolchain, Docker Engine and normal browser prerequisites, the same gate can be run from a clean checkout: - -```sh -bash scripts/ci-preflight.sh -``` - -Shell orchestration is covered by `pnpm exec vitest run --coverage.enabled=false src/lib/ci-preflight.test.ts`. Those tests execute the real shell script with external command boundaries simulated; they prove ordering, failure propagation, unique tags and cleanup scope. They do not build an image or run the news browser suite. The branch/PR CI job provides that Docker/browser evidence. diff --git a/docs/operations/cms-error-center.md b/docs/operations/cms-error-center.md deleted file mode 100644 index edb57d41..00000000 --- a/docs/operations/cms-error-center.md +++ /dev/null @@ -1,94 +0,0 @@ -# CMS error center and dependency maintenance - -Open **HK > DevOps > CMS error center** (`/admin/devops/cms-errors`). -The previous `/admin/devops/errors` page still displays emulator errors. - -## Access and workflow - -- Read: existing `admin.devops.view` permission, checked on the server. -- Mark resolved: `admin.devops.edit`, checked again in the server action; recorded in the staff audit log. -- Search by event reference, Next.js digest, route, message or deployment version. -- Expand a group for its latest stack, sanitized context and occurrence references. -- Marking a group resolved does not delete evidence. A later occurrence reopens it. -- Refresh is manual so inspecting an expanded error is not interrupted by polling. - -## What is collected - -Pino `logger.error`, `logServerError`, unexpected action errors, Next.js request -failures, React error boundaries, uncaught browser errors and unhandled browser -promise rejections. API wrapper failures return an `errorId`; Next.js boundary -errors can be correlated by their digest. Release identifiers come from the build. -Browser reports retain their own client release separately from the receiving server. - -Reports are stored in `storage/cms-errors`, using the existing persistent storage -mount. No third-party service, token or new database table is required. -Storage does not depend on database availability; viewing the HK and its permission -checks still require authentication/database availability. Server console logs -remain the fallback when the CMS itself cannot serve requests. - -Retention: seven days; approximately 10 MB/day, 100 queued server writes, and the -latest 1,000 events in the viewer. Limits are per CMS process/storage volume; -this is intended for the existing single-instance deployment. Daily expiry runs -on the next write. The browser endpoint is same-origin, body-size bounded and -rate-limited. Browser reports are untrusted observations and cannot grant access. -Known credential patterns and URL parameters are redacted; arbitrary object -metadata and request bodies are excluded. Avoid putting personal data in error -messages: this is pattern-based redaction, not a universal data-loss filter. - -This does not collect historical console logs, process crashes before framework -startup, nginx failures, all `console.error` calls, or every handled business -validation error. A browser stack may point at minified chunks; private source-map -symbolication and distributed traces are not part of this local viewer. A recorded -stack is diagnostic evidence, not an automatic root-cause determination. - -## Dependencies - -`pnpm install --frozen-lockfile`, `pnpm deps:audit`, `pnpm typecheck`, `pnpm test`, -`pnpm biome:lint`, and `pnpm build` are the verification sequence. -`pnpm analyze --output` writes a Next.js bundle analysis (not an application build). - -TinyMCE 8.9 is pinned in pnpm. `pnpm assets:editor` copies its runtime files and -license notices to ignored `public/vendor/tinymce`; dev/build run this first. -The Docker build includes the generated assets. The editor retains its existing -HTML fields and toolbar; validate saved content and preview when upgrading it. - -Lenis has been removed; the public site now uses native scrolling. Other used -runtime libraries remain. Vitest and its coverage provider are upgraded together; -`clearMocks: false` preserves initialization-time permission contract assertions. - -Dependency updates run through self-hosted Renovate: `renovate.json` plus -`.gitea/workflows/renovate.yaml` run the pinned `renovate/renovate` image on the -host runner every weekday at 04:17 (and via `workflow_dispatch`). Minor and patch -updates are grouped into one PR and automerged only after CI is green -(`platformAutomerge: false`, so Renovate waits on the checks itself); major -updates require approval on the dependency dashboard. The job authenticates with -the `renovate-bot` account token stored as the repo Actions secret -`RENOVATE_TOKEN`; rotation only requires overwriting that secret (the bot -password lives in `/root/.gitea-renovate-bot.env` on the host). `minimumReleaseAge` -is 3 days in `renovate.json` and 60 hours in `pnpm-workspace.yaml`, so neither the -installer nor the bot picks up a fresh release on day zero. Node/pnpm upgrades -remain coordinated with Docker and the runner toolchain. -Obsolete global overrides were removed; a scoped esbuild override remains because -Drizzle Kit's loader still resolves a vulnerable legacy development-server build. -The two deprecated esbuild-kit packages remain upstream dependencies of Drizzle -Kit; replacing the ORM is not warranted for this tooling issue. - -## HK request performance - -Open **DevOps → Request performance** (`/admin/devops/performance`). Access requires `DEVOPS_VIEW`; collection only retains requests that passed authentication and permission checks through `withAdmin`. - -The view shows recent requests, their server duration, completed database query count/time/errors, monitored curl download count/time/errors, HTTP status and the existing operation ID. Search by route or operation ID, sort by duration or recency, and filter requests taking at least one second. - -### Measurement boundaries - -- Duration ends when the handler returns its response. Streaming completion, background jobs, browser rendering and public pages are not measured. -- Database spans wrap the shared mysql2 promise pool and transaction connection query/execute calls. Query parameters and SQL are never collected. Explicit transaction connection acquisition and transaction control methods are not separate spans. -- External spans currently cover `curlFetchText` and `curlDownload`; ordinary fetch calls and other integrations are not covered. -- Concurrent dependency durations can exceed wall-clock request duration. Do not subtract the sums to infer application CPU time. -- Dynamic route values and query strings are removed. No request bodies, headers, usernames, download URLs or SQL text are stored. - -### Storage and operation - -No new environment variables or packages are required. Redis stores at most 200 recent samples under `cms:performance:v1`, with one-hour retention. Writes are best effort, restricted to an already-ready connection and at most four pending batches; the request never waits for persistence. The view reads at most 200 entries and falls back after one second if shared storage is unavailable. - -An in-process buffer preserves up to 200 samples during outages. The page explicitly labels this local mode; it is per instance and disappears on restart. Filtering applies to the retained samples, not complete traffic history. Under load or during storage outages, some shared samples may be omitted. diff --git a/docs/operations/docker-installation.md b/docs/operations/docker-installation.md deleted file mode 100644 index 4c05d1ef..00000000 --- a/docs/operations/docker-installation.md +++ /dev/null @@ -1,120 +0,0 @@ -# Install a clone and choose an update - -## Requirements and first installation - -Use a Linux host with Docker Engine, the Compose plugin, Git and `flock`. This Compose file uses host networking and port 3002. Provide an existing compatible Habbo MariaDB database, reachable Redis, persistent storage and an HTTP(S) reverse proxy. The installer does not provision the emulator, a database, TLS, or a registry account. - -Clone this repository from the Gitea URL supplied by your administrator, enter the clone, then run: - -```sh -bash cms install -``` - -The wizard asks for the public URL and delivery mode. **Source** (the default for a new installation) builds the locked source locally and only needs repository access plus access to public build dependencies. **Prebuilt** downloads the application and migrations from the repository's `docker-image.txt`; a private package needs a separate registry login with package-read permission. Git access alone may not grant package access. Existing saved mode and `.env` are preserved. `bash cms install --configure-only` saves configuration without starting containers. - -Credentials are entered on the host, never in the dashboard. Do not paste `.env` into support tickets. Installation prepares `public/nitro-assets`, `public/swf`, `storage`, and `/var/www/Gamedata` for UID/GID 33 without recursively taking ownership of existing files. Existing nested assets may still need operator permission repair. The updater tests access to those mounts as the candidate container user before migrations; this checks directory access, not every nested file or filesystem capacity. - -After startup, visit `/admin/devops/installation` with the appropriate permission. Verify database, Redis, storage and migration status. Worker heartbeat is a separate runtime signal: a healthy HTTP endpoint does not prove an import worker is processing jobs. Check the worker status and investigate a missing/stale heartbeat before scheduling imports. The dashboard is read-only and cannot start Docker, upgrade the host or grant registry access. - -## Client IP trust at the reverse proxy - -The application validates and normalizes client addresses from `cf-connecting-ip`, the first `x-forwarded-for` entry, then `x-real-ip`. It never accepts `x-real-client-ip`; that legacy derived header is also stripped by the Next.js proxy. Missing or invalid addresses resolve to `0.0.0.0` for rate limits and audit records. API routes use the same resolver even though they do not run through the Next.js proxy. - -These headers are trustworthy only when the ingress sanitizes them. Configure the reverse proxy to discard client-supplied forwarding/derived headers and replace the accepted address from a verified connection or a specifically trusted upstream proxy. Do not append an untrusted incoming `x-forwarded-for` chain and then treat its first entry as authoritative. Forward `cf-connecting-ip` only after verifying that it came through your trusted CDN path; otherwise remove it. - -Restrict direct access to the application port so requests must pass through that ingress. The provided Compose file uses host networking with `HOSTNAME=0.0.0.0`; it does not enforce this restriction or provision nginx/Traefik trust rules. Verify the host firewall and actual reverse-proxy configuration before relying on client IPs for blocking, auditing or abuse limits. Repository tests prove rejection of the derived-header bypass and malformed addresses; they do not certify the deployed forwarding trust chain. - -## Opt-in profile: Nginx on the same host - -Use this profile for a new Linux Compose clone whose public HTTPS endpoint is Nginx on that same host. It leaves `docker-compose.yml` and CI-managed production deployments unchanged. Nginx must include `ngx_http_realip_module`; check `nginx -V` before using the templates. The CMS remains on the existing host network for database/Redis connectivity, but its HTTP process listens on `127.0.0.1:3002`. Host networking shares the host network namespace; a `ports:` mapping would not provide the restriction. See [Docker host networking](https://docs.docker.com/engine/network/drivers/host/). - -1. Run `bash cms install --configure-only` and choose the intended public HTTPS URL. Obtain a valid certificate for that hostname using the host's existing certificate-management process. The templates do not issue certificates or configure renewal. -2. In the clone's existing `.env`, add or replace this single setting, preserving all other values: - - ```dotenv - COMPOSE_FILE=docker-compose.yml:deployment/proxy/compose.loopback.yml - ``` - - Keep `APP_URL`, `AUTH_URL` and the saved installer public URL on the same canonical `https://` hostname. The profile pins the existing port 3002 as well as the loopback address. Do not place `COMPOSE_FILE` in `.docker-install`; that file accepts only `MODE` and `PUBLIC_URL`. -3. Copy [nginx-direct.example.conf](../../deployment/proxy/nginx-direct.example.conf) into the host's Nginx configuration directory, outside this Git clone. Replace **every** `hotel.example` and both certificate paths. Load it at `http` scope, for example through `/etc/nginx/conf.d/cms.conf`. Review any existing virtual host for that hostname to avoid two competing configurations. The example handles only CMS HTTP traffic; emulator WebSocket and other hotel services need their own reviewed ingress. -4. Validate the effective Nginx configuration with `nginx -t`, then enable/reload it through the host's normal service-management procedure. Prepare this endpoint before starting the installer, because installation verifies the saved public URL. It can return an upstream-unavailable response until the CMS starts. -5. From the clone root, remove conflicting Compose overrides from the deployment shell and validate the model: - - ```sh - unset COMPOSE_FILE COMPOSE_PATH_SEPARATOR COMPOSE_ENV_FILES COMPOSE_DISABLE_ENV_FILE - docker compose config --quiet - bash cms install - ``` - - These `unset` commands remove shell overrides; they do not remove the `COMPOSE_FILE` line in `.env`. Do not set `COMPOSE_DISABLE_ENV_FILE=1`, use an alternate `--env-file`, or supply a competing shell `COMPOSE_FILE` for this workflow. Environment values can override `.env` selection. Do not print or paste the full rendered Compose configuration, because it contains runtime credentials. See [Compose predefined variables and precedence](https://docs.docker.com/compose/how-tos/environment-variables/envvars/). -6. Confirm the running configuration without dumping the environment: - - ```sh - docker compose exec -T cms node -e 'if(process.env.HOSTNAME!=="127.0.0.1"||process.env.PORT!=="3002")process.exit(1);console.log("CMS configured for 127.0.0.1:3002")' - ss -lnt '( sport = :3002 )' - curl --fail --silent --show-error https://hotel.example/api/health - ``` - - The listener must be `127.0.0.1:3002`, not `0.0.0.0:3002` or `[::]:3002`. From another machine, the host's public IP on port 3002 must be unreachable. Check both address families when the host has IPv6. Loopback isolation covers the CMS process only: other containers and host services, including Byparr, retain their existing bindings. - -The direct template uses the original socket peer (`$realip_remote_addr`), overwrites the two accepted forwarding headers, and removes incoming `CF-Connecting-IP`, `X-Real-Client-IP` and `Forwarded`. It fixes forwarded host/protocol to the configured HTTPS origin. An unrelated inherited real-IP rule cannot turn a caller-supplied header into the forwarded client address in this mode. See [Nginx original-peer variables](https://nginx.org/en/docs/http/ngx_http_realip_module.html) and [header replacement/removal](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_set_header). - -Response buffering is disabled for progress streams, and this example adds no proxy response cache or CORS policy. Its 64 MiB ingress body cap accommodates the existing 52 MiB Studio attachment limit; route and Server Action limits remain authoritative and may be lower. TLS 1.2/1.3 are configured explicitly. See [Nginx buffering](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering) and [TLS protocols](https://nginx.org/en/docs/http/ngx_http_ssl_module.html#ssl_protocols). - -### Why the profile survives an update - -The installer preserves an existing `.env`. The installer invokes `docker-update.sh`, and the updater's config, build, candidate run, cutover and rollback paths all call **bare `docker compose` from the clone root**, without `-f`. Compose therefore reads the saved file list on each invocation. The base file appears first so its relative mount/build paths remain rooted in the clone; the later profile overrides only the CMS environment and healthcheck. No installer/updater patch is required for this selection. See [Compose merge order and relative paths](https://docs.docker.com/compose/how-tos/multiple-compose-files/merge/). - -Keep the profile selected for every routine `bash cms update` and selected-release update. A one-off `docker compose -f ... up` does not persist selection for later installer/updater commands. Do not add an untracked `docker-compose.override.yml`: it makes the clone dirty unless separately excluded and is bypassed when `COMPOSE_FILE` selects an explicit list. Before selecting an older release, verify that `deployment/proxy/compose.loopback.yml` exists in that release; a missing selected file fails configuration rather than silently using the public bind. - -This profile does not change `scripts/ci-deploy.sh`, which owns a separate `docker run` deployment and currently sets its own public binding. Do not use the clone installer to take over a host managed by CI. Restricting that deployment's listener requires a separate compatibility review and rollout. - -### Separate mode: verified remote edge or Cloudflare - -A remote proxy cannot connect to the CMS loopback listener directly. The supported topology here is **remote edge → TLS → Nginx on the CMS host → loopback CMS**, retaining the same Compose profile. Use [nginx-trusted-proxy.example.conf](../../deployment/proxy/nginx-trusted-proxy.example.conf) instead of the direct template. Do not enable both templates for one hostname. - -For your own remote edge, replace `203.0.113.10/32` in both the `geo` peer allowlist and `set_real_ip_from` with the exact approved connection source addresses. The reserved example address deliberately permits no real edge. Require the edge to overwrite `X-Forwarded-For` with one verified client IP, use the configured hostname, enforce public HTTPS, and validate this origin's TLS certificate. The origin checks the original socket peer before accepting the rewritten address. Never use `0.0.0.0/0`, `::/0` or arbitrary client networks as trusted proxies. See [Nginx real-IP trust configuration](https://nginx.org/en/docs/http/ngx_http_realip_module.html). - -For Cloudflare, use that same restricted-edge mode and replace both peer lists with the **current verified IPv4 and IPv6 Cloudflare ranges**, maintained by the operator; use `real_ip_header CF-Connecting-IP` instead of `X-Forwarded-For`. Configure Full (strict) TLS and review authenticated origin pulls. Obtain ranges from [Cloudflare's official IP list](https://www.cloudflare.com/ips/) and follow its [visitor-IP restoration guidance](https://developers.cloudflare.com/support/troubleshooting/restoring-visitor-ips/restoring-original-visitor-ips/). Do not copy a historical range list from a support ticket or trust `CF-Connecting-IP` merely because it is present. This setup still removes the CF header before the CMS and forwards only Nginx's normalized result in XFF/X-Real-IP. - -The direct template intentionally records the CDN/edge socket address when placed behind an unconfigured CDN; it does not silently trust an upstream header. After configuring the restricted-edge mode, verify with requests from an allowed edge and a disallowed direct client, including forged CF/XFF headers, and check the recorded client address. Neither the repository nor the installer changes the host firewall or certifies another proxy's header behavior. - -### Local verification and deployment boundary - -`pnpm exec vitest run --coverage.enabled=false scripts/proxy-config.test.mjs` runs the installed Docker Compose CLI against a disposable clone configuration, without contacting Docker Engine, building images or reading the real `.env`. It verifies selection through `.env`, loopback/port override precedence, the IPv4 healthcheck and preservation of mounts, release selection and host networking. The `cms` service is behind the `cms` profile (so a bare `docker compose up -d` cannot claim port 3002 on a CI-managed host), so the test renders the configuration with `--profile cms`. It explicitly skips when Compose is unavailable. This is not a container-start or network-isolation test. - -`pnpm test:integration` additionally starts disposable Nginx containers from the actual templates, supplies a temporary test certificate, and sends real HTTPS requests with forged identity headers. It checks direct-mode replacement even with an inherited real-IP rule, rejection of untrusted peers, and acceptance through an explicitly trusted peer. This requires Docker Engine and the OpenSSL CLI and does not read deployment credentials. The templates must still pass `nginx -t` on the intended host after its hostname/certificate substitution, then the listener and trusted-header checks above; the disposable fixture cannot certify that host or its firewall. - -## Routine and selected-release updates - -```sh -bash cms update -``` - -This requires a clean clone and fast-forwards its configured Git upstream, then builds/pulls artifacts for that exact commit. It validates the application and migration revision labels, validates runtime configuration and storage, runs migrations and verifies the recreated CMS locally and through the saved public URL. - -To install a specific published version, fetch it and select its commit on the host first: - -```sh -git fetch origin -git switch --detach -bash cms update --skip-pull -``` - -`--skip-pull` deliberately uses the checked-out commit, including detached HEAD. For routine updates again, switch back to your tracked deployment branch. The script does not invent version-to-schema compatibility or automatically change branches. - -In prebuilt mode you can additionally require immutable artifacts from the configured repository: - -```sh -bash cms update --skip-pull --app-digest sha256:<64-lowercase-hex> --migrations-digest sha256:<64-lowercase-hex> -``` - -Replace both placeholders with publisher-provided digests. Both are mandatory together. Revision labels must match the checked-out commit; a digest alone does not establish schema compatibility. Older migration images without a revision label must be republished from matching source, or the same checkout can be installed in source mode. No migration runs when the artifact or candidate validation fails. - -## Compatibility and recovery - -Before upgrading, read the selected release's migration changes and take a database backup with a tested restore procedure. Preserve `.env`, persistent assets and the previous release identifier. The current tooling does not provide a validated matrix of supported source/target database versions. Pinning an old commit is therefore not a supported database downgrade procedure. - -On a failure after container replacement, the updater attempts to restore the previous image and checks it locally. **Image rollback does not reverse database migrations.** A migration may partially apply or make the old application incompatible, including when migration fails before container replacement. Recover the database only through the reviewed backup/restore procedure and coordinate downtime; do not assume restarting the old image recovers it. First installation has no prior image to restore. Host logs remain in `logs/docker-update.log` and may contain application/database diagnostics; restrict access. - -Local Git Bash tests cover selection validation and mocked failure paths. They do not establish Linux container startup, runtime filesystem permissions, registry availability or real MariaDB upgrade compatibility. diff --git a/docs/operations/personal-tokens/README.md b/docs/operations/personal-tokens/README.md deleted file mode 100644 index 9b62e946..00000000 --- a/docs/operations/personal-tokens/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Personal API token scopes - -Public API bearer authentication accepts only tokens owned by the exact `App\Models\User` model. The owner ID must be a positive, safely representable user ID, and the token must satisfy its existing expiration check. Both plaintext tokens and the existing `{id}|{plaintext}` request format remain supported; only the SHA-256 hash is looked up in the database. - -The `abilities` column must contain a non-empty JSON array of non-empty strings. Null, malformed JSON, non-array JSON, empty arrays, non-string entries, and entries with surrounding whitespace are rejected. A valid `"*"` entry grants access to all existing bearer-protected endpoints. Other permissions match exactly: there is no `tickets:*` expansion, implicit read/write inheritance, or fallback to unrestricted access. - -| Ability | Endpoint access | -| --- | --- | -| `tickets:read` | `GET /api/tickets`, `GET /api/tickets/{id}` | -| `tickets:write` | `POST /api/tickets`, `POST /api/tickets/{id}/reply` | -| `articles:write` | `POST /api/articles/{slug}/comment` | -| `radio:read` | `GET /api/radio/points` | -| `radio:write` | `POST /api/radio/shouts` | -| `badges:read` | Personal viewer data in `GET /api/badges/leaderboard` | - -For example, `["tickets:read","radio:read"]` allows reading the owner's tickets and radio points. It cannot create tickets, send replies, post article comments, or send radio shouts. Endpoint ownership checks and rate limits still apply after scope authorization. - -Required-token endpoints return the existing generic `401 Unauthorized` response when authorization fails. The badge leaderboard remains public: a denied bearer token receives the anonymous view, without personal viewer data. When an Authorization header is present, this endpoint does not use a session cookie to bypass a denied token. Session-only requests continue to personalize the leaderboard normally. - -## Compatibility and maintenance - -Existing valid wildcard tokens remain compatible. The existing session-authenticated `POST /api/tokens` endpoint continues issuing `["*"]`; this change does not add token-creation options or alter stored tokens. Legacy null, malformed, empty, differently cased model names, and unrelated model tokens are intentionally denied. Review and replace affected tokens with explicit intended scopes, or reissue through the existing token endpoint when full access is appropriate. - -Every new bearer-authenticated endpoint must pass its required abilities to `bearerUserId`. Multiple required abilities use AND semantics. Omitting the requirements, or passing an empty list, requires a wildcard token rather than granting arbitrary scoped tokens access. - -No plaintext token or stored hash is added to error responses or logs by these checks. The existing issuance endpoint returns plaintext once by design. diff --git a/docs/operations/security-review-2026-09-13.md b/docs/operations/security-review-2026-09-13.md deleted file mode 100644 index cde4a68d..00000000 --- a/docs/operations/security-review-2026-09-13.md +++ /dev/null @@ -1,31 +0,0 @@ -# Security report verification — 2026-09-13 - -The supplied review describes commit `baeb54ae` plus a separate port for another hotel. Its “Fixed” labels were not evidence that the changes existed in EpicNext-Cms. This verification inspected canonical `main` at `52f6d149` and the corrective changes prepared here. No exploit or authenticated mutation was performed against production. - -| Supplied finding | Verified state in baseline | Correction / remaining boundary | -| --- | --- | --- | -| 1. Logo authorization/upload | Confirmed missing action permission and per-file validation | Require settings edit before input or storage access; bounded decoded raster uploads | -| 2. Favicon authorization/delete | Confirmed missing action permission; SVG accepted | Same permission boundary for create/delete, bounded raster/ICO validation | -| 3. Active uploaded SVG | Confirmed SVG served inline without route CSP | Route CSP sandbox and nosniff on success/errors; existing SVG served as attachment | -| 4. Client IP spoofing | Confirmed direct trust in caller-controlled `x-real-client-ip` | Shared validated resolver ignores that header. Forwarded headers still require trusted ingress that overwrites them and prevents direct public origin access | -| 5. Email token action exports | Confirmed token helpers in a `use server` module | Move token creation/validation and delivery to a server-only module. Registration and verification call it internally | -| 6. Locale cookie | Confirmed missing allowlist | Supported locales only, validate before reading/writing cookies | -| 7. Email header injection | Confirmed unsanitized values in sendmail headers | Reject control characters before any mail transport or file fallback; includes configured sender | -| 8. Gateway CORS | Supplied gateway path is outside this repository | Read-only GET to our `/api/health` with an unrelated Origin returned a fixed `https://epicnabbo.nl` allow-origin and no allow-credentials. This does not reproduce the report on that route, nor certify every host/route | -| 9. Token abilities | Confirmed abilities and owner type not checked by bearer authentication | Enforce User owner type and explicit endpoint abilities; existing wildcard user tokens remain supported | -| 10. Broad script CDN | Confirmed unrestricted jsDelivr script source, without a source-code consumer | Remove the broad script source; retain required captcha/analytics sources and nonce | - -The additional `withNitroStaff` code and its tests mentioned in the supplied port do not exist in this checkout; they were not assumed to have been reviewed or imported. - -## Evidence and limits - -- Regression tests exercise authorization before I/O, actual file decoding, SVG/error response headers, token-boundary exports, token abilities, forged derived-IP headers across consumers, locale values, and mail header control characters. -- An updated `pnpm audit --json` reported zero known advisories. This is a dependency database result, not proof that application code has no vulnerabilities. -- Next.js treats exported Server Actions as public endpoints; unused actions can also be removed by the compiler. The email refactor removes the action boundary entirely instead of relying on whether a specific build exports an unused helper. See [Next.js data security](https://nextjs.org/docs/app/guides/data-security). -- The framework also has its own Server Action body limit. The logo defect was absence of application-level file validation, not evidence of literally unlimited bytes through every deployment layer. -- No live database, user accounts, uploaded files, or gateway configuration were modified during verification. These changes do not constitute a penetration test or an audit of the emulator, host, or all CMS endpoints. -- No nginx/Traefik ingress configuration is versioned here. The deployment guide records the forwarding-header trust requirement. That external boundary remains unverified. - -## Follow-up identified during verification - -The separate comment review is now implemented: both the website form and REST API use one submission service, require a published article whose publication time is due, apply the same moderation, and share a five-attempt/30-second per-user quota. The publication check locks the current article in the insertion transaction. Regression tests cover both entrypoints; real MariaDB/Redis coverage includes publication eligibility, word filtering and alternating submissions. Moderation retains its existing fail-open behavior on service outages. Form input beyond 255 characters is now rejected instead of truncated, and temporary API storage failures return 503. Real integration execution remains a required CI check. diff --git a/docs/superpowers/plans/2026-08-02-furni-import-hotel-source.md b/docs/superpowers/plans/2026-08-02-furni-import-hotel-source.md deleted file mode 100644 index 67f876bd..00000000 --- a/docs/superpowers/plans/2026-08-02-furni-import-hotel-source.md +++ /dev/null @@ -1,111 +0,0 @@ -# Furni Import Hotel Source Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make the furni import visibly and consistently use the CMS `habbo_gamedata_hotel` setting without Italy-specific names or messages. - -**Architecture:** Keep locale selection centralized in `getHabboGamedataHotel()` and expose normalized display metadata to the server-rendered import page. Rename the furnidata cache contract to generic official-Habbo terminology, and keep asset downloads on the global `images.habbo.com` CDN. - -**Tech Stack:** TypeScript 7, React 19, Next.js 16, Vitest 4. - -## Global Constraints - -- `habbo_gamedata_hotel` remains the single source of truth. -- Furnidata and external texts use `www.habbo.`. -- SWF and icon downloads remain on `images.habbo.com`. -- Cache entries must never leak across two configured hotels. -- Existing import behavior and permissions remain unchanged. - ---- - -### Task 1: Generic official-Habbo furnidata contract - -**Files:** -- Create: `src/lib/services/habbo-furnidata-cache.test.ts` -- Modify: `src/types/furni.ts` -- Modify: `src/lib/services/habbo-furnidata-cache.ts` -- Modify: `src/lib/services/habbofurni.ts` -- Modify: `src/lib/services/furni-data.ts` -- Modify: `src/lib/services/furni-import.ts` -- Modify: `src/actions/admin-settings.ts` -- Modify: `src/app/api/admin/import/furni/route.ts` -- Modify: `src/app/api/admin/import/furni/resync/route.ts` -- Modify: `src/app/api/admin/import/furni/batch-regen/route.ts` - -**Interfaces:** -- Produces: `OfficialHabboFurniEntry`. -- Produces: `getOfficialHabboFurnidata()`, `lookupOfficialHabboFurni()`, and `clearOfficialHabboFurnidataCache()`. -- Consumes: `getHabboGamedataHotel()` and `habboFurnidataUrl(hotel)`. - -- [ ] **Step 1: Write a failing cache-isolation test** - -Mock the selected hotel as `it` for the first request and `nl` for the second. Return distinct fixtures from `fetch` and assert that the second call returns the Dutch fixture and requests `https://www.habbo.nl/gamedata/furnidata_json/1`. - -- [ ] **Step 2: Run the cache test and verify RED** - -Run: `pnpm exec vitest run --coverage=false src/lib/services/habbo-furnidata-cache.test.ts` - -Expected: FAIL because the generic official-Habbo API is not exported yet. - -- [ ] **Step 3: Rename the cache contract and consumers** - -Rename the Italy-specific type and functions throughout the import pipeline. Keep the existing hotel-keyed cache behavior and update comments to say “configured official Habbo hotel”. - -- [ ] **Step 4: Run the cache test and verify GREEN** - -Run: `pnpm exec vitest run --coverage=false src/lib/services/habbo-furnidata-cache.test.ts` - -Expected: PASS with separate `it` and `nl` fetches. - -### Task 2: Display and report the configured source - -**Files:** -- Create: `src/app/admin/import/furni/import-source.test.ts` -- Create: `src/app/admin/import/furni/import-source.ts` -- Modify: `src/app/admin/import/furni/page.tsx` -- Modify: `src/app/admin/import/furni/import-furni-client.tsx` -- Modify: `src/lib/services/furni-import.ts` -- Modify: `src/lib/services/furni-import.test.ts` - -**Interfaces:** -- Produces: `FurniImportSource { hotel, label, host, furnidataUrl }`. -- Produces: `getFurniImportSource()` for the server page. -- Produces: `formatOfficialHabboEnrichmentWarning(hotel, name)`. - -- [ ] **Step 1: Write failing source and message tests** - -Assert that an `nl` setting becomes `{ hotel: "nl", label: "Netherlands (habbo.nl)", host: "habbo.nl", furnidataUrl: "https://www.habbo.nl/gamedata/furnidata_json/1" }` and that enrichment reports `Enriched from habbo.nl`. - -- [ ] **Step 2: Run the tests and verify RED** - -Run: `pnpm exec vitest run --coverage=false src/app/admin/import/furni/import-source.test.ts src/lib/services/furni-import.test.ts` - -Expected: FAIL because source metadata and the dynamic warning formatter do not exist. - -- [ ] **Step 3: Implement source metadata and UI** - -Read the normalized hotel on the server page, pass source metadata into `ImportFurniClient`, and render a compact source badge/link above the furni controls. Replace literal `habbo.it` enrichment wording with the resolved host. - -- [ ] **Step 4: Run focused verification** - -Run: `pnpm exec vitest run --coverage=false src/lib/services/habbo-furnidata-cache.test.ts src/app/admin/import/furni/import-source.test.ts src/lib/services/furni-import.test.ts` - -Expected: PASS. - -- [ ] **Step 5: Run repository verification** - -Run: - -```text -pnpm typecheck -pnpm test -pnpm build -``` - -Expected: all commands exit with status 0 using the same non-secret production validation environment as CI where required. - -- [ ] **Step 6: Commit the implementation** - -```text -fix: use configured Habbo hotel in furni import -``` diff --git a/docs/superpowers/plans/2026-08-02-manual-recent-furni-resync.md b/docs/superpowers/plans/2026-08-02-manual-recent-furni-resync.md deleted file mode 100644 index 7afaa363..00000000 --- a/docs/superpowers/plans/2026-08-02-manual-recent-furni-resync.md +++ /dev/null @@ -1,357 +0,0 @@ -# Manual Recent Furni Resync Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a guarded Tools command to Admin Import Furni that resyncs furniture imported during the last seven days and displays the operation result. - -**Architecture:** Keep the existing permission-protected `/api/admin/import/furni/resync` route unchanged. Add a client-safe request/normalization helper with an injected fetcher so its exact URL, HTTP method, success payload, partial failures, and transport failures are testable without rendering the large Import Furni client. The existing client component owns the confirmation, loading, and result UI state. - -**Tech Stack:** TypeScript, React 19, Next.js 16 App Router, Vitest, shadcn/Radix UI, Sonner, Biome. - -## Global Constraints - -- The operation targets only `furni_import` audit entries from the last seven days. -- Use the existing `ASSETS_IMPORT`-protected endpoint and `adminFetch` CSRF flow. -- Do not expose `all=1`, delete database rows, or regenerate `.nitro`, SWF, or icon files. -- Display examined, resynced, failed, RCON status, and returned per-item errors. -- Prevent duplicate submissions while a request is active. - ---- - -### Task 1: Tested recent-resync client contract - -**Files:** -- Create: `src/lib/admin/recent-furni-resync.ts` -- Test: `src/lib/admin/recent-furni-resync.test.ts` - -**Interfaces:** -- Consumes: a fetcher matching `(input: RequestInfo | URL, init?: RequestInit) => Promise`. -- Produces: `RECENT_FURNI_RESYNC_URL`, `RecentFurniResyncError`, `RecentFurniResyncResult`, and `requestRecentFurniResync(fetcher): Promise`. - -- [ ] **Step 1: Write the failing request-contract tests** - -Create `src/lib/admin/recent-furni-resync.test.ts`: - -```ts -import { describe, expect, it, vi } from "vitest"; -import { - RECENT_FURNI_RESYNC_URL, - requestRecentFurniResync, -} from "./recent-furni-resync"; - -describe("requestRecentFurniResync", () => { - it("posts to the fixed seven-day resync endpoint and normalizes the result", async () => { - const fetcher = vi.fn(async () => - Response.json({ - ok: true, - mode: "days", - days: 7, - examined: 4, - resynced: 3, - failed: 1, - rconOk: false, - errors: [{ classname: "chair", message: "invalid entry" }], - }), - ); - - await expect(requestRecentFurniResync(fetcher)).resolves.toEqual({ - examined: 4, - resynced: 3, - failed: 1, - rconOk: false, - errors: [{ classname: "chair", message: "invalid entry" }], - }); - expect(RECENT_FURNI_RESYNC_URL).toBe( - "/api/admin/import/furni/resync?days=7", - ); - expect(fetcher).toHaveBeenCalledWith(RECENT_FURNI_RESYNC_URL, { - method: "POST", - }); - }); - - it("throws the API error when the request fails", async () => { - const fetcher = vi.fn(async () => - Response.json({ error: "Forbidden" }, { status: 403 }), - ); - - await expect(requestRecentFurniResync(fetcher)).rejects.toThrow( - "Forbidden", - ); - }); -}); -``` - -- [ ] **Step 2: Run the tests and verify RED** - -Run: - -```powershell -pnpm exec vitest run --coverage=false src/lib/admin/recent-furni-resync.test.ts -``` - -Expected: FAIL because `recent-furni-resync.ts` does not exist. - -- [ ] **Step 3: Implement the minimal typed request helper** - -Create `src/lib/admin/recent-furni-resync.ts`: - -```ts -export const RECENT_FURNI_RESYNC_URL = - "/api/admin/import/furni/resync?days=7"; - -export interface RecentFurniResyncError { - classname: string; - message: string; -} - -export interface RecentFurniResyncResult { - examined: number; - resynced: number; - failed: number; - rconOk: boolean; - errors: RecentFurniResyncError[]; -} - -type AdminFetcher = ( - input: RequestInfo | URL, - init?: RequestInit, -) => Promise; - -export async function requestRecentFurniResync( - fetcher: AdminFetcher, -): Promise { - const response = await fetcher(RECENT_FURNI_RESYNC_URL, { method: "POST" }); - const payload = (await response.json()) as Record; - if (!response.ok) { - throw new Error( - typeof payload.error === "string" ? payload.error : "Furni resync failed", - ); - } - - const errors = Array.isArray(payload.errors) - ? payload.errors.filter( - (value): value is RecentFurniResyncError => - typeof value === "object" && - value !== null && - typeof (value as RecentFurniResyncError).classname === "string" && - typeof (value as RecentFurniResyncError).message === "string", - ) - : []; - - return { - examined: Number(payload.examined) || 0, - resynced: Number(payload.resynced) || 0, - failed: Number(payload.failed) || 0, - rconOk: payload.rconOk === true, - errors, - }; -} -``` - -- [ ] **Step 4: Run focused tests and verify GREEN** - -Run: - -```powershell -pnpm exec vitest run --coverage=false src/lib/admin/recent-furni-resync.test.ts -pnpm exec biome check --write src/lib/admin/recent-furni-resync.ts src/lib/admin/recent-furni-resync.test.ts -``` - -Expected: 2 tests pass and Biome reports no remaining errors. - -- [ ] **Step 5: Commit the tested contract** - -```powershell -git add -- src/lib/admin/recent-furni-resync.ts src/lib/admin/recent-furni-resync.test.ts -git commit -m "feat: add recent furni resync client contract" -``` - -### Task 2: Import Furni Tools action and result UI - -**Files:** -- Modify: `src/app/admin/import/furni/import-furni-client.tsx` -- Consume: `src/lib/admin/recent-furni-resync.ts` - -**Interfaces:** -- Consumes: `requestRecentFurniResync(adminFetch): Promise`. -- Produces: a `Tools → Update imported furni` action, confirmation dialog, loading state, and dismissible result panel. - -- [ ] **Step 1: Add state and the guarded request handler** - -Import `DatabaseZap`, `RecentFurniResyncResult`, and -`requestRecentFurniResync`. Add state alongside the existing reorganization -state: - -```ts -const [confirmRecentResync, setConfirmRecentResync] = useState(false); -const [recentResyncing, setRecentResyncing] = useState(false); -const [recentResyncResult, setRecentResyncResult] = - useState(null); -``` - -Add the handler near `startReorganize`: - -```ts -async function resyncRecentImports() { - setConfirmRecentResync(false); - setRecentResyncing(true); - setRecentResyncResult(null); - try { - const result = await requestRecentFurniResync(adminFetch); - setRecentResyncResult(result); - if (result.examined === 0) { - toast.info("No imported furni found in the last 7 days"); - } else if (result.failed > 0 || !result.rconOk) { - toast.warning("Furni resync completed with warnings"); - } else { - toast.success(`Updated ${result.resynced} imported furni`); - } - fetchStats(); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Furni resync failed", - ); - } finally { - setRecentResyncing(false); - } -} -``` - -- [ ] **Step 2: Add the Tools entry and duplicate-submit guard** - -Insert before the Tools separator: - -```tsx - setConfirmRecentResync(true)} - disabled={recentResyncing} -> - {recentResyncing ? ( - - ) : ( - - )} - {recentResyncing ? "Updating imported furni..." : "Update imported furni"} - -``` - -- [ ] **Step 3: Add the confirmation dialog** - -Add a controlled dialog beside the existing batch confirmation dialogs: - -```tsx - - - - Update imported furni? - - This updates FurnitureData for furni imported during the last 7 days, - then refreshes the emulator catalog and item caches. No database rows or - asset files are deleted. - - - - setConfirmRecentResync(false)}> - Cancel - - - Update last 7 days - - - - -``` - -- [ ] **Step 4: Render a dismissible result panel** - -Place the panel after the existing error banner and before batch summaries: - -```tsx -{recentResyncResult && ( - - - Imported Furni Update - setRecentResyncResult(null)} - > - Dismiss - - - - {recentResyncResult.examined} examined - - {recentResyncResult.resynced} updated - - - {recentResyncResult.failed} failed - - - RCON {recentResyncResult.rconOk ? "refreshed" : "not refreshed"} - - - {recentResyncResult.errors.length > 0 && ( - - - View errors - - - {recentResyncResult.errors.map((error) => ( - - {error.classname}: {error.message} - - ))} - - - )} - -)} -``` - -- [ ] **Step 5: Run focused and static verification** - -```powershell -pnpm exec biome check --write src/app/admin/import/furni/import-furni-client.tsx src/lib/admin/recent-furni-resync.ts src/lib/admin/recent-furni-resync.test.ts -pnpm exec vitest run --coverage=false src/lib/admin/recent-furni-resync.test.ts src/lib/services/furni-data-paths.test.ts -pnpm typecheck -``` - -Expected: Biome clean, focused tests pass, and TypeScript exits 0. - -- [ ] **Step 6: Run complete regression verification** - -```powershell -pnpm test -$env:NODE_ENV='production' -$env:DATABASE_URL='mysql://test:test@localhost:3306/test?charset=utf8mb4' -$env:AUTH_SECRET='ci-test-secret-key-that-is-long-enough' -pnpm build -``` - -Expected: all tests pass and the production build exits 0. Redis/database -availability warnings during static generation are acceptable in the local test -environment; compilation or route-generation errors are not. - -- [ ] **Step 7: Commit the UI integration** - -```powershell -git add -- src/app/admin/import/furni/import-furni-client.tsx -git commit -m "feat: add manual recent furni resync action" -``` - -- [ ] **Step 8: Verify the final repository state** - -```powershell -git status --short -git log --oneline origin/main..HEAD -``` - -Expected: clean worktree and the design, plan, tested helper, and UI commits are -ahead of `origin/main`, ready for an explicit push request. diff --git a/docs/superpowers/plans/2026-08-02-production-furni-assets.md b/docs/superpowers/plans/2026-08-02-production-furni-assets.md deleted file mode 100644 index 01248342..00000000 --- a/docs/superpowers/plans/2026-08-02-production-furni-assets.md +++ /dev/null @@ -1,126 +0,0 @@ -# Production Furni Assets Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make every furni import write the icon, Nitro bundle, and FurnitureData entry into the directories served by the production client under `/var/www/Gamedata`. - -**Architecture:** Extend the central furni asset resolver with a separate Gamedata layout while preserving CMS-local and Nitro-Files targets. Both import paths consume the same resolved targets; FurnitureData resolves the same Gamedata root independently so metadata and binary assets stay aligned. - -**Tech Stack:** TypeScript 7, Node.js filesystem APIs, Vitest 4, Next.js 16. - -## Global Constraints - -- Preserve all existing `nitro_files_root`, `furni_*_dir`, and `furni_data_mirror_path` behavior. -- Auto-detect `/var/www/Gamedata` only when the directory exists; allow `gamedata_root` to override it. -- Do not copy source SWFs into the Gamedata tree. -- Report live mirror failures in the import warnings. -- Use test-first development and run the production build before completion. - ---- - -### Task 1: Resolve the production Gamedata layout - -**Files:** -- Create: `src/lib/services/furni-asset-dirs.test.ts` -- Modify: `src/lib/services/furni-asset-dirs.ts` -- Modify: `src/lib/services/furni-data.ts` -- Modify: `src/app/admin/settings/cms-settings-config.ts` - -**Interfaces:** -- Produces: `getGamedataRoot(): Promise`. -- Produces: Gamedata mirror entries from `getFurniAssetWriteTargets()` with `/icons` and `/bundled/furniture`. -- Consumes: `siteSettings.get("gamedata_root", "")` and `existsSync(DEFAULT_GAMEDATA_ROOT)`. - -- [ ] **Step 1: Write failing resolver tests** - -Mock `siteSettings.get` and `existsSync`, then assert that a configured Gamedata root produces: - -```ts -expect(targets.mirrorDirs).toContainEqual({ - swfDir: targets.swfDir, - iconDir: path.join(gamedataRoot, "icons"), - nitroDir: path.join(gamedataRoot, "bundled/furniture"), -}); -``` - -Also assert that an absent unconfigured root adds no Gamedata mirror and that a duplicate primary destination is de-duplicated. - -- [ ] **Step 2: Run the resolver test and verify RED** - -Run: `pnpm exec vitest run --coverage=false src/lib/services/furni-asset-dirs.test.ts` - -Expected: FAIL because `gamedata_root` is not read and the Gamedata mirror is absent. - -- [ ] **Step 3: Implement the Gamedata resolver** - -Add `DEFAULT_GAMEDATA_ROOT`, `getGamedataRoot()`, and a pure Gamedata mapping that uses the primary `swfDir` while mapping icons and Nitro bundles to the live locations. Merge this candidate with the existing Nitro-Files candidate and remove identical directory triples. - -- [ ] **Step 4: Extend FurnitureData and the settings form** - -Make `getFurnitureDataWritePaths()` include `/config/FurnitureData.json`, after any explicit `furni_data_mirror_path`. Add a documented `gamedata_root` text setting with `/var/www/Gamedata` as the placeholder. - -- [ ] **Step 5: Run resolver tests and verify GREEN** - -Run: `pnpm exec vitest run --coverage=false src/lib/services/furni-asset-dirs.test.ts` - -Expected: PASS. - -- [ ] **Step 6: Commit Task 1** - -```text -fix: map furni imports to production gamedata -``` - -### Task 2: Mirror manual Nitro uploads - -**Files:** -- Create: `src/lib/services/upload-import.test.ts` -- Modify: `src/lib/services/upload-import.ts` - -**Interfaces:** -- Consumes: `getFurniAssetWriteTargets(): Promise`. -- Produces: manual upload copies at every unique `mirrorDirs[].iconDir` and `mirrorDirs[].nitroDir`. - -- [ ] **Step 1: Write a failing manual-upload test** - -Mock the database and metadata dependencies, provide temporary primary and Gamedata directories, call `uploadSingleFurni`, and assert: - -```ts -await expect(fs.readFile(path.join(liveNitroDir, "chair.nitro"))).resolves.toEqual(nitroBuffer); -await expect(fs.readFile(path.join(liveIconDir, "chair_icon.png"))).resolves.toEqual(iconBuffer); -``` - -- [ ] **Step 2: Run the upload test and verify RED** - -Run: `pnpm exec vitest run --coverage=false src/lib/services/upload-import.test.ts` - -Expected: FAIL because manual uploads currently write only to the primary CMS directories. - -- [ ] **Step 3: Implement manual mirroring** - -Resolve all write targets, create their directories through the existing `ensureDirectories()`, and copy the successfully written primary Nitro and optional icon files to each unique mirror. Catch each copy error separately and append a warning containing the destination path. - -- [ ] **Step 4: Run the upload test and verify GREEN** - -Run: `pnpm exec vitest run --coverage=false src/lib/services/upload-import.test.ts` - -Expected: PASS. - -- [ ] **Step 5: Run focused and full verification** - -Run: - -```text -pnpm exec vitest run --coverage=false src/lib/services/furni-asset-dirs.test.ts src/lib/services/upload-import.test.ts src/lib/services/furni-import.test.ts -pnpm typecheck -pnpm test -pnpm build -``` - -Expected: every command exits with status 0. - -- [ ] **Step 6: Commit Task 2** - -```text -fix: mirror manual furni uploads to live assets -``` diff --git a/docs/superpowers/plans/2026-08-24-housekeeping-inventory-foundation.md b/docs/superpowers/plans/2026-08-24-housekeeping-inventory-foundation.md deleted file mode 100644 index 171b4b62..00000000 --- a/docs/superpowers/plans/2026-08-24-housekeeping-inventory-foundation.md +++ /dev/null @@ -1,1401 +0,0 @@ -# Housekeeping Inventory & Foundation Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Produce the complete 137-page HK migration inventory and a tested, non-production Command Deck foundation without changing the current `/admin` or `/mod` experience. - -**Architecture:** Add a modular `src/features/housekeeping` boundary containing migration evidence, capability-aware contracts, a validated domain registry, shell primitives, and a non-production preview. Existing authentication and ACL data remain authoritative through `getAdminContext()`; no business workflow is migrated in this subproject. - -**Tech Stack:** Node.js `>=26.7.0 <27`, pnpm `11.22.0`, Next.js `16.3.2`, React `19.2.8`, TypeScript `7.0.2`, Vitest `4.1.11`, Zod `4.4.3`, next-intl `4.13.7`, Tailwind CSS `4.3.3`, Biome `2.5.9`. - -**Spec:** `docs/superpowers/specs/2026-08-24-housekeeping-modernization-design.md` - -## Global Constraints - -- Work directly in `E:\Users\simol\Desktop\EpicNext-cms`; do not create a Git worktree or another checkout. -- Preserve all unrelated tracked, deleted, untracked, and committed local work. Stage only the files named by the current task. -- Pull from the configured canonical upstream before execution. This repository has `origin/main` and no `origin/dev`. -- Keep the current `/admin` and `/mod` route behavior unchanged throughout this subproject. -- Do not expose the preview in production. `NODE_ENV=production` must disable it even if the preview flag is set. -- Do not add operational inbox aggregation, entity search, command execution, domain mutations, preferences persistence, redirects, legacy deletion, or database migrations. -- Reuse `getAdminContext()` and the existing `PermissionSet`; do not add rank thresholds or duplicate ACL queries. -- Rank may influence presentation defaults only. Effective capability slugs determine visibility and access. -- Use only semantic `--admin-*` theme tokens for HK chrome. Do not add hard-coded Tailwind palette colors to ordinary HK UI. -- Put source copy under `pages.housekeeping` in `src/messages/en.json` and `src/messages/it.json`; all other locales use the repository's English fallback. -- Keep files focused. Foundation modules must not import `@/lib/db`, `@/actions/*`, or domain page modules. -- Use TDD: observe the requested failing assertion before writing its implementation, then run the narrow test before widening validation. -- Use CRLF-compatible edits and run `git diff --check` before every commit. - -## Locked File Structure - -```text -src/features/housekeeping/ - migration/ - types.ts migration row and evidence types - discover-legacy-pages.ts filesystem-to-route discovery - validate-matrix.ts structural and coverage validation - operations.ts `/admin` dashboard row - people.ts people, support, moderation, all `/mod` rows - content.ts editorial and engagement rows - economy.ts economy and catalog rows - hotel.ts hotel, radio, rooms, studio rows - system.ts configuration, ACL, logs, DevOps rows - matrix.ts aggregate export only - *.test.ts per-domain audit contracts - foundation/ - contracts/ - capability.ts capability requirements - result.ts typed success/error outcomes - domain.ts domain and route manifests - query.ts query contract - command.ts command metadata/runtime contract - search.ts search provider contract - inbox.ts derived work-item/source contract - widget.ts dashboard widget contract - index.ts public contract barrel - capability-context.ts pure capability helpers - server-capability-context.ts `getAdminContext()` adapter - registry.ts manifest validation and visibility - navigation.ts registry-to-shell navigation model - preview-gate.ts non-production flag policy - shell/ - housekeeping-shell.tsx Command Deck frame - domain-rail.tsx six-domain primary navigation - context-nav.tsx active-domain navigation - command-trigger.tsx disabled phase-01 command affordance - operator-summary.tsx authenticated actor summary - page/ - housekeeping-page-shell.tsx standard title/action/content frame - housekeeping-page-state.tsx loading/empty/partial/error states - domains/ - operations/manifest.ts - people/manifest.ts - content/manifest.ts - economy/manifest.ts - hotel/manifest.ts - system/manifest.ts - manifests.ts aggregate of the six manifests -src/app/admin-next/ - layout.tsx global preview gate - page.tsx first-visible-domain redirect - [domain]/layout.tsx capability-filtered Command Deck - [domain]/page.tsx foundation-only domain placeholder -scripts/verify-housekeeping-matrix.ts -``` - -The feature folder contains no database access and no migrated business workflow. The only route added is `/admin-next`, which returns 404 outside an explicitly enabled non-production environment. - ---- - -### Task 1: Legacy page discovery and migration schema - -**Files:** -- Create: `src/features/housekeeping/migration/types.ts` -- Create: `src/features/housekeeping/migration/discover-legacy-pages.ts` -- Create: `src/features/housekeeping/migration/validate-matrix.ts` -- Test: `src/features/housekeeping/migration/discover-legacy-pages.test.ts` -- Test: `src/features/housekeeping/migration/validate-matrix.test.ts` - -**Interfaces:** -- Consumes: filesystem roots `src/app/admin` and `src/app/mod`. -- Produces: `discoverLegacyPages(rootDir?: string): LegacyPage[]`, `ownedLegacyPages(prefixes: readonly string[], pages?: readonly LegacyPage[]): LegacyPage[]`, `validateMigrationEntries(discovered: readonly LegacyPage[], entries: readonly MigrationEntry[]): string[]`, and the migration types used by Tasks 2-7. - -- [ ] **Step 1: Write the failing discovery tests** - -```ts -import { describe, expect, it } from "vitest"; -import { discoverLegacyPages } from "./discover-legacy-pages"; - -describe("discoverLegacyPages", () => { - it("discovers the exact legacy administration inventory", () => { - const pages = discoverLegacyPages(); - expect(pages).toHaveLength(137); - expect(pages).toContainEqual({ - surface: "admin", - legacyPath: "/admin/users/:id/edit", - sourceFile: "src/app/admin/users/[id]/edit/page.tsx", - }); - expect(pages).toContainEqual({ - surface: "mod", - legacyPath: "/mod/cfh/:id", - sourceFile: "src/app/mod/cfh/[id]/page.tsx", - }); - }); - - it("sorts by surface, route, then source file", () => { - const pages = discoverLegacyPages(); - expect(pages).toEqual([...pages].sort((a, b) => - `${a.surface}:${a.legacyPath}:${a.sourceFile}`.localeCompare( - `${b.surface}:${b.legacyPath}:${b.sourceFile}`, - ), - )); - }); -}); -``` - -- [ ] **Step 2: Run the discovery test and confirm the red state** - -Run: `pnpm exec vitest run src/features/housekeeping/migration/discover-legacy-pages.test.ts` - -Expected: FAIL because `discover-legacy-pages.ts` does not exist. - -- [ ] **Step 3: Define the exact migration types** - -```ts -export const HOUSEKEEPING_DOMAIN_IDS = [ - "operations", - "people", - "content", - "economy", - "hotel", - "system", -] as const; - -export type HousekeepingDomainId = (typeof HOUSEKEEPING_DOMAIN_IDS)[number]; -export type LegacySurface = "admin" | "mod"; -export type MigrationDecision = "REHOST" | "REBUILD" | "MERGE" | "REMOVE"; -export type AuditRequirement = "NONE" | "MUTATION" | "PRIVILEGED_MUTATION"; -export type AuditState = "UNAUDITED" | "PARTIAL" | "COMPLETE"; -export type MigrationStatus = "PLANNED" | "IN_PROGRESS" | "VERIFIED" | "REMOVED"; - -export interface LegacyPage { - surface: LegacySurface; - legacyPath: string; - sourceFile: string; -} - -export interface MigrationEntry extends LegacyPage { - targetDomain: HousekeepingDomainId; - targetPath: string | null; - decision: MigrationDecision; - capabilities: { read: readonly string[]; mutate: readonly string[] }; - dependencies: { queries: readonly string[]; mutations: readonly string[] }; - auditRequirement: AuditRequirement; - localization: AuditState; - accessibility: AuditState; - requiredTests: readonly ("unit" | "integration" | "e2e" | "visual")[]; - parityEvidence: readonly string[]; - status: MigrationStatus; - notes: readonly string[]; -} -``` - -- [ ] **Step 4: Implement deterministic route discovery** - -Use `readdirSync(..., { withFileTypes: true })`, recurse only below the two locked roots, retain `page.tsx`, remove route-group segments such as `(group)`, and convert `[id]` to `:id` and `[...slug]` to `:slug*`. Normalize every file path with `/` separators before sorting. - -```ts -export function discoverLegacyPages(rootDir = process.cwd()): LegacyPage[] { - return (["admin", "mod"] as const) - .flatMap((surface) => discoverSurface(rootDir, surface)) - .sort((a, b) => - `${a.surface}:${a.legacyPath}:${a.sourceFile}`.localeCompare( - `${b.surface}:${b.legacyPath}:${b.sourceFile}`, - ), - ); -} - -export function ownedLegacyPages( - prefixes: readonly string[], - pages: readonly LegacyPage[] = discoverLegacyPages(), -): LegacyPage[] { - return pages.filter((page) => prefixes.some((prefix) => - page.legacyPath === prefix || page.legacyPath.startsWith(`${prefix}/`), - )); -} -``` - -- [ ] **Step 5: Write the failing validator tests** - -```ts -const page = (legacyPath: string): LegacyPage => ({ - surface: legacyPath.startsWith("/mod") ? "mod" : "admin", - legacyPath, - sourceFile: `src/app${legacyPath}/page.tsx`, -}); - -const entry = (legacyPath: string): MigrationEntry => ({ - ...page(legacyPath), - targetDomain: "system", - targetPath: "/admin/system/example", - decision: "REHOST", - capabilities: { read: ["admin.dashboard"], mutate: [] }, - dependencies: { queries: [], mutations: [] }, - auditRequirement: "NONE", - localization: "COMPLETE", - accessibility: "COMPLETE", - requiredTests: ["unit"], - parityEvidence: [], - status: "PLANNED", - notes: [], -}); - -it("reports missing, duplicate, and unknown legacy rows", () => { - const discovered = [page("/admin"), page("/admin/users")]; - const issues = validateMigrationEntries(discovered, [ - entry("/admin"), - entry("/admin"), - entry("/admin/ghost"), - ]); - expect(issues).toEqual([ - "duplicate legacyPath: /admin", - "missing legacyPath: /admin/users", - "unknown legacyPath: /admin/ghost", - ]); -}); - -it("rejects incomplete decisions", () => { - const issues = validateMigrationEntries([page("/admin")], [ - { ...entry("/admin"), targetPath: null, decision: "REBUILD" }, - ]); - expect(issues).toContain("REBUILD requires targetPath: /admin"); -}); -``` - -- [ ] **Step 6: Implement structural validation** - -Validation must return sorted strings and enforce: - -- one entry for every discovered page and no unknown entry; -- `targetPath === null` only for `REMOVE`; -- non-`REMOVE` targets start with `/admin/`; -- `MERGE` and `REBUILD` declare at least one required test; -- mutation dependencies require mutation capabilities and non-`NONE` audit; -- `VERIFIED` requires non-empty parity evidence; -- no string field contains `TBD`, `TODO`, `FIXME`, or `UNCLASSIFIED`. - -- [ ] **Step 7: Run the narrow tests** - -Run: `pnpm exec vitest run src/features/housekeeping/migration/discover-legacy-pages.test.ts src/features/housekeeping/migration/validate-matrix.test.ts` - -Expected: PASS with 137 discovered pages. - -- [ ] **Step 8: Commit Task 1** - -```powershell -git add src/features/housekeeping/migration/types.ts src/features/housekeeping/migration/discover-legacy-pages.ts src/features/housekeeping/migration/validate-matrix.ts src/features/housekeeping/migration/discover-legacy-pages.test.ts src/features/housekeeping/migration/validate-matrix.test.ts -git diff --cached --check -git commit -m "test: define housekeeping migration inventory" -``` - -### Task 2: Operations and People migration audit - -**Files:** -- Create: `src/features/housekeeping/migration/operations.ts` -- Create: `src/features/housekeeping/migration/people.ts` -- Test: `src/features/housekeeping/migration/operations.test.ts` -- Test: `src/features/housekeeping/migration/people.test.ts` - -**Interfaces:** -- Consumes: `discoverLegacyPages()`, `MigrationEntry`, and `validateMigrationEntries()` from Task 1. -- Produces: `operationsMigrationEntries` and `peopleMigrationEntries` with one reviewed row for every owned page. - -Owned prefixes are exact: - -```ts -const PEOPLE_PREFIXES = [ - "/admin/users", "/admin/online", "/admin/guilds", "/admin/applications", - "/admin/teams", "/admin/bans", "/admin/ip", "/admin/vpn", - "/admin/wordfilter", "/admin/moderation", "/admin/tickets", - "/admin/help-tickets", "/mod", -] as const; -``` - -Operations owns only `/admin` in this subproject. - -- [ ] **Step 1: Write failing ownership tests** - -```ts -it("covers the legacy dashboard once", () => { - expect(operationsMigrationEntries).toHaveLength(1); - expect(operationsMigrationEntries[0]).toMatchObject({ - legacyPath: "/admin", - targetDomain: "operations", - targetPath: "/admin/work", - decision: "REBUILD", - status: "PLANNED", - }); -}); - -it("merges every mod page into a people workflow", () => { - const modRows = peopleMigrationEntries.filter((row) => row.surface === "mod"); - expect(modRows).toHaveLength(13); - expect(modRows.every((row) => row.decision === "MERGE")).toBe(true); - expect(modRows.every((row) => row.targetPath?.startsWith("/admin/people/"))) - .toBe(true); -}); -``` - -- [ ] **Step 2: Run the tests and confirm missing audit modules** - -Run: `pnpm exec vitest run src/features/housekeeping/migration/operations.test.ts src/features/housekeeping/migration/people.test.ts` - -Expected: FAIL because both entry arrays are missing. - -- [ ] **Step 3: Audit Operations** - -Read `src/app/admin/page.tsx` and record its real Drizzle query dependencies, current localization state, current accessibility state, and required parity checks. Use this locked decision: - -```ts -{ - surface: "admin", - legacyPath: "/admin", - sourceFile: "src/app/admin/page.tsx", - targetDomain: "operations", - targetPath: "/admin/work", - decision: "REBUILD", - capabilities: { read: [PERMS.ADMIN_DASHBOARD], mutate: [] }, - dependencies: { - queries: ["users", "active bans", "website articles", "staff activities"], - mutations: [], - }, - auditRequirement: "NONE", - localization: "COMPLETE", - accessibility: "PARTIAL", - requiredTests: ["integration", "e2e", "visual"], - parityEvidence: [], - status: "PLANNED", - notes: ["Replace metric dashboard with capability-derived operational home"], -} -``` - -- [ ] **Step 4: Audit People route groups in small batches** - -Review these batches separately so every row names actual imported actions/services: - -1. users, multi-account aliases, online, guilds, applications; -2. teams, moderation, CFH, bans, IP, VPN, word filter; -3. tickets and help tickets; -4. all 13 `/mod` pages. - -Primary `/admin` workflows may be `REHOST` only when their current UI, ACL, localization, and service boundary are already sound. Duplicate show/edit aliases and every `/mod` route are `MERGE`. Targets use `/admin/people/users`, `/admin/people/community`, `/admin/people/staff`, `/admin/people/moderation`, or `/admin/people/support` plus stable `:id` suffixes. - -- [ ] **Step 5: Add per-domain validation** - -```ts -const expected = discoverLegacyPages().filter( - (page) => page.legacyPath === "/admin" || - PEOPLE_PREFIXES.some((prefix) => - page.legacyPath === prefix || page.legacyPath.startsWith(`${prefix}/`), - ), -); -expect(validateMigrationEntries(expected, [ - ...operationsMigrationEntries, - ...peopleMigrationEntries, -])).toEqual([]); -``` - -- [ ] **Step 6: Run and commit the audit** - -Run: `pnpm exec vitest run src/features/housekeeping/migration/operations.test.ts src/features/housekeeping/migration/people.test.ts` - -Expected: PASS; exactly 13 `mod` rows are `MERGE`. - -```powershell -git add src/features/housekeeping/migration/operations.ts src/features/housekeeping/migration/people.ts src/features/housekeeping/migration/operations.test.ts src/features/housekeeping/migration/people.test.ts -git diff --cached --check -git commit -m "docs: audit housekeeping people workflows" -``` - -### Task 3: Content and Engagement migration audit - -**Files:** -- Create: `src/features/housekeeping/migration/content.ts` -- Test: `src/features/housekeeping/migration/content.test.ts` - -**Interfaces:** -- Consumes: Task 1 discovery and validation. -- Produces: `contentMigrationEntries` for every path below the locked prefixes. - -```ts -const CONTENT_PREFIXES = [ - "/admin/articles", "/admin/photos", "/admin/media", "/admin/banners", - "/admin/ads", "/admin/events", "/admin/polls", "/admin/help-questions", - "/admin/tags", "/admin/prefixes", "/admin/writeable-boxes", - "/admin/email-templates", "/admin/theme", "/admin/favicon", - "/admin/translations", -] as const; -``` - -- [ ] **Step 1: Write the failing coverage and target tests** - -```ts -it("covers every content page without legacy-domain targets", () => { - const expected = ownedLegacyPages(CONTENT_PREFIXES); - expect(validateMigrationEntries(expected, contentMigrationEntries)).toEqual([]); - expect(contentMigrationEntries.every((row) => - row.targetPath === null || row.targetPath.startsWith("/admin/content/"), - )).toBe(true); -}); -``` - -- [ ] **Step 2: Confirm the red state** - -Run: `pnpm exec vitest run src/features/housekeeping/migration/content.test.ts` - -Expected: FAIL because `contentMigrationEntries` is missing. - -- [ ] **Step 3: Audit editorial and media batches** - -Inspect collection, create, and detail pages for articles, photos, media, banners, and ads. Record actual action/service imports in `dependencies`; preserve `:id` in target paths. Classify duplicate create/detail variants as `MERGE` only when one target workflow owns the same state. - -- [ ] **Step 4: Audit engagement and help-content batches** - -Inspect events, polls, help questions, tags, prefixes, writable boxes, and email templates. Targets use `/admin/content/editorial`, `/admin/content/media`, `/admin/content/engagement`, or `/admin/content/help`. - -- [ ] **Step 5: Audit brand and localization batches** - -Inspect theme, favicon, and translations. Targets use `/admin/content/brand` and `/admin/content/localization`. Mark mutation pages `MUTATION` or `PRIVILEGED_MUTATION` based on whether they affect global runtime configuration. - -- [ ] **Step 6: Run and commit the audit** - -Run: `pnpm exec vitest run src/features/housekeeping/migration/content.test.ts` - -Expected: PASS with every content-owned legacy page represented once. - -```powershell -git add src/features/housekeeping/migration/content.ts src/features/housekeeping/migration/content.test.ts -git diff --cached --check -git commit -m "docs: audit housekeeping content workflows" -``` - -### Task 4: Economy and Catalog migration audit - -**Files:** -- Create: `src/features/housekeeping/migration/economy.ts` -- Test: `src/features/housekeeping/migration/economy.test.ts` - -**Interfaces:** -- Consumes: Task 1 discovery and validation. -- Produces: `economyMigrationEntries` for the locked economy prefixes. - -```ts -const ECONOMY_PREFIXES = [ - "/admin/catalog", "/admin/items", "/admin/shop", "/admin/marketplace", - "/admin/transactions", "/admin/vouchers", "/admin/subscriptions", - "/admin/rare-values", "/admin/badges", "/admin/achievements", - "/admin/sounds", "/admin/calendar", -] as const; -``` - -- [ ] **Step 1: Write the failing economy audit test** - -```ts -it("requires privileged audit for economy mutations", () => { - const mutationRows = economyMigrationEntries.filter( - (row) => row.dependencies.mutations.length > 0, - ); - expect(mutationRows.length).toBeGreaterThan(0); - expect(mutationRows.every( - (row) => row.auditRequirement === "PRIVILEGED_MUTATION", - )).toBe(true); -}); -``` - -- [ ] **Step 2: Confirm the red state** - -Run: `pnpm exec vitest run src/features/housekeeping/migration/economy.test.ts` - -Expected: FAIL because the audit module is missing. - -- [ ] **Step 3: Audit catalog and item workflows** - -Inspect catalog collection/detail/builder-club/maintenance and item collection/detail pages. Record direct database access separately from service/action dependencies. Target `/admin/economy/catalog` and `/admin/economy/items`; mark overlapping editors `MERGE` when they mutate the same catalog entity. - -- [ ] **Step 4: Audit commerce and value workflows** - -Inspect shop, marketplace, transactions, vouchers, subscriptions, rare values, badges, achievements, sounds, and calendar. Use `/admin/economy/commerce`, `/admin/economy/history`, `/admin/economy/value`, and `/admin/economy/rewards` targets. - -- [ ] **Step 5: Validate the domain inventory** - -```ts -expect(validateMigrationEntries( - ownedLegacyPages(ECONOMY_PREFIXES), - economyMigrationEntries, -)).toEqual([]); -``` - -- [ ] **Step 6: Run and commit the audit** - -Run: `pnpm exec vitest run src/features/housekeeping/migration/economy.test.ts` - -Expected: PASS and every economy mutation row requires privileged audit. - -```powershell -git add src/features/housekeeping/migration/economy.ts src/features/housekeeping/migration/economy.test.ts -git diff --cached --check -git commit -m "docs: audit housekeeping economy workflows" -``` - -### Task 5: Hotel and World migration audit - -**Files:** -- Create: `src/features/housekeeping/migration/hotel.ts` -- Test: `src/features/housekeeping/migration/hotel.test.ts` - -**Interfaces:** -- Consumes: Task 1 discovery and validation. -- Produces: `hotelMigrationEntries` for rooms, navigator, radio, and studio. - -```ts -const HOTEL_PREFIXES = [ - "/admin/rooms", "/admin/navigation", "/admin/radio", "/admin/studio", -] as const; -``` - -- [ ] **Step 1: Write the failing hotel coverage test** - -```ts -it("maps all hotel tools below the hotel target root", () => { - expect(validateMigrationEntries( - ownedLegacyPages(HOTEL_PREFIXES), - hotelMigrationEntries, - )).toEqual([]); - expect(hotelMigrationEntries.every((row) => - row.targetPath === null || row.targetPath.startsWith("/admin/hotel/"), - )).toBe(true); -}); -``` - -- [ ] **Step 2: Confirm the red state** - -Run: `pnpm exec vitest run src/features/housekeeping/migration/hotel.test.ts` - -Expected: FAIL because the hotel audit module is missing. - -- [ ] **Step 3: Audit rooms and navigator** - -Inspect room list/show/edit/furni paths and navigator management. Merge duplicate room detail aliases into `/admin/hotel/rooms/:id`; keep furni as `/admin/hotel/rooms/:id/furni`. - -- [ ] **Step 4: Audit radio and studio** - -Inspect all radio and studio pages, including API keys, monitoring, moderation, imports, sync, maintenance, and audit surfaces. Record external RCON/API/filesystem dependencies explicitly and mark credential or runtime mutation pages `PRIVILEGED_MUTATION`. - -- [ ] **Step 5: Run and commit the audit** - -Run: `pnpm exec vitest run src/features/housekeeping/migration/hotel.test.ts` - -Expected: PASS with all owned pages represented once. - -```powershell -git add src/features/housekeeping/migration/hotel.ts src/features/housekeeping/migration/hotel.test.ts -git diff --cached --check -git commit -m "docs: audit housekeeping hotel workflows" -``` - -### Task 6: System audit and complete 137-row matrix - -**Files:** -- Create: `src/features/housekeeping/migration/system.ts` -- Create: `src/features/housekeeping/migration/matrix.ts` -- Create: `scripts/verify-housekeeping-matrix.ts` -- Modify: `package.json: scripts` -- Test: `src/features/housekeeping/migration/system.test.ts` -- Test: `src/features/housekeeping/migration/matrix.test.ts` - -**Interfaces:** -- Consumes: all migration arrays from Tasks 2-5. -- Produces: `HOUSEKEEPING_MIGRATION_MATRIX` and the `pnpm hk:matrix:check` CLI. - -```ts -const SYSTEM_PREFIXES = [ - "/admin/alerts", "/admin/analytics", "/admin/commandocentrum", - "/admin/devops", "/admin/emulator", "/admin/housekeeping", - "/admin/logs", "/admin/maintenance", "/admin/menu", - "/admin/permissions", "/admin/settings", -] as const; -``` - -- [ ] **Step 1: Write the failing system decisions** - -```ts -it("removes legacy foundation-owned pages", () => { - expect(systemMigrationEntries).toEqual(expect.arrayContaining([ - expect.objectContaining({ legacyPath: "/admin/housekeeping", decision: "REMOVE", targetPath: null }), - expect.objectContaining({ legacyPath: "/admin/menu", decision: "REMOVE", targetPath: null }), - ])); -}); - -it("keeps live permissions as a system workflow", () => { - expect(systemMigrationEntries).toContainEqual(expect.objectContaining({ - legacyPath: "/admin/permissions", - targetPath: "/admin/system/access/permissions", - decision: expect.not.stringMatching("REMOVE"), - })); -}); -``` - -- [ ] **Step 2: Confirm the red state** - -Run: `pnpm exec vitest run src/features/housekeeping/migration/system.test.ts` - -Expected: FAIL because `systemMigrationEntries` is missing. - -- [ ] **Step 3: Audit system route groups** - -Inspect alerts, analytics, command center, DevOps, emulator, logs, maintenance, permissions, and settings. Targets use `/admin/system/access`, `/admin/system/configuration`, `/admin/system/observability`, or `/admin/system/operations`. Preserve the read-only value of the legacy HK export/comparison in the row notes even though its route decision is `REMOVE`. - -- [ ] **Step 4: Write the failing aggregate coverage test** - -```ts -it("covers all 137 legacy pages exactly once", () => { - const discovered = discoverLegacyPages(); - expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(137); - expect(validateMigrationEntries( - discovered, - HOUSEKEEPING_MIGRATION_MATRIX, - )).toEqual([]); -}); - -it("contains no undecided evidence markers", () => { - expect(JSON.stringify(HOUSEKEEPING_MIGRATION_MATRIX)).not.toMatch( - /TBD|TODO|FIXME|UNCLASSIFIED/, - ); -}); -``` - -- [ ] **Step 5: Aggregate the six arrays without adding behavior** - -```ts -export const HOUSEKEEPING_MIGRATION_MATRIX = [ - ...operationsMigrationEntries, - ...peopleMigrationEntries, - ...contentMigrationEntries, - ...economyMigrationEntries, - ...hotelMigrationEntries, - ...systemMigrationEntries, -].sort((a, b) => a.legacyPath.localeCompare(b.legacyPath)); -``` - -- [ ] **Step 6: Add the CLI verifier** - -`scripts/verify-housekeeping-matrix.ts` calls `validateMigrationEntries(discoverLegacyPages(), HOUSEKEEPING_MIGRATION_MATRIX)`. It prints every returned issue to stderr and sets `process.exitCode = 1`; a valid matrix prints `Housekeeping migration matrix: 137/137 valid`. Add: - -```json -"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts" -``` - -- [ ] **Step 7: Run and commit the complete matrix** - -Run: - -```powershell -pnpm exec vitest run src/features/housekeeping/migration -pnpm hk:matrix:check -``` - -Expected: all migration tests PASS and CLI prints `137/137 valid`. - -```powershell -git add src/features/housekeeping/migration/system.ts src/features/housekeeping/migration/matrix.ts src/features/housekeeping/migration/system.test.ts src/features/housekeeping/migration/matrix.test.ts scripts/verify-housekeeping-matrix.ts package.json -git diff --cached --check -git commit -m "docs: complete housekeeping migration matrix" -``` - -### Task 7: Foundation contract types - -**Files:** -- Create: `src/features/housekeeping/foundation/contracts/capability.ts` -- Create: `src/features/housekeeping/foundation/contracts/result.ts` -- Create: `src/features/housekeeping/foundation/contracts/domain.ts` -- Create: `src/features/housekeeping/foundation/contracts/query.ts` -- Create: `src/features/housekeeping/foundation/contracts/command.ts` -- Create: `src/features/housekeeping/foundation/contracts/search.ts` -- Create: `src/features/housekeeping/foundation/contracts/inbox.ts` -- Create: `src/features/housekeeping/foundation/contracts/widget.ts` -- Create: `src/features/housekeeping/foundation/contracts/index.ts` -- Test: `src/features/housekeeping/foundation/contracts/contracts.test.ts` - -**Interfaces:** -- Consumes: `HousekeepingDomainId` from migration types only. -- Produces: every public foundation type used by Tasks 8-11. - -- [ ] **Step 1: Write failing constructor tests** - -```ts -it("creates typed success and error results", () => { - expect(ok({ count: 2 }, "corr-1")).toEqual({ - ok: true, - data: { count: 2 }, - correlationId: "corr-1", - }); - expect(fail("CAPABILITY_DENIED", "corr-2")).toEqual({ - ok: false, - error: { code: "CAPABILITY_DENIED" }, - correlationId: "corr-2", - }); -}); - -it("rejects an empty capability requirement", () => { - expect(() => anyCapability()).toThrow("capability requirement is empty"); -}); -``` - -- [ ] **Step 2: Confirm the red state** - -Run: `pnpm exec vitest run src/features/housekeeping/foundation/contracts/contracts.test.ts` - -Expected: FAIL because the contract barrel does not exist. - -- [ ] **Step 3: Define capability and result contracts** - -```ts -export type CapabilityRequirement = - | { mode: "all"; slugs: readonly string[] } - | { mode: "any"; slugs: readonly string[] }; - -export interface HousekeepingActor { - id: number; - username: string; - rank: number; -} - -export interface HousekeepingCapabilityContext { - actor: HousekeepingActor; - isSuperAdmin: boolean; - has(slug: string): boolean; - hasAny(...slugs: string[]): boolean; - hasAll(...slugs: string[]): boolean; -} - -export type HousekeepingErrorCode = - | "VALIDATION_FAILED" | "AUTHENTICATION_REQUIRED" | "CAPABILITY_DENIED" - | "NOT_FOUND" | "CONFLICT" | "DEPENDENCY_UNAVAILABLE" - | "PARTIAL_RESULT" | "INTERNAL_ERROR"; -``` - -- [ ] **Step 4: Define domain, query, and command contracts** - -```ts -export interface HousekeepingRouteDefinition { - id: string; - labelKey: string; - href: string; - capability: CapabilityRequirement; - matchPrefixes?: readonly string[]; -} - -export interface HousekeepingDomainManifest { - id: HousekeepingDomainId; - labelKey: string; - descriptionKey: string; - iconId: "inbox" | "users" | "file-text" | "gem" | "hotel" | "settings"; - previewHref: `/admin-next/${HousekeepingDomainId}`; - capability: CapabilityRequirement; - routes: readonly HousekeepingRouteDefinition[]; -} - -export interface HousekeepingQuery { - id: string; - owner: HousekeepingDomainId; - capability: CapabilityRequirement; - run(context: HousekeepingCapabilityContext, input: I): Promise>; -} - -export interface HousekeepingCommand { - id: string; - owner: HousekeepingDomainId; - risk: "safe" | "sensitive"; - capability: CapabilityRequirement; - requiresReason: boolean; - execute(context: HousekeepingCapabilityContext, input: I): Promise>; -} -``` - -- [ ] **Step 5: Define search, inbox, and widget contracts** - -Use these exact public methods: - -- `HousekeepingSearchProvider.search(context, { term, limit }): Promise>` -- `HousekeepingInboxSource.getItems(context, signal): Promise>` -- `HousekeepingWidgetDefinition.load(context): Promise>` - -`HousekeepingWorkItem` contains `sourceId`, `itemId`, `deduplicationKey`, `domain`, `capability`, `severity`, `occurredAt`, `titleKey`, `context`, `href`, and `freshness`. No assignment, dismissal, completion, or task-owner field is permitted. - -- [ ] **Step 6: Run tests and typecheck the public barrel** - -Run: - -```powershell -pnpm exec vitest run src/features/housekeeping/foundation/contracts/contracts.test.ts -pnpm typecheck -``` - -Expected: PASS; no type or unused-export errors. - -- [ ] **Step 7: Commit Task 7** - -```powershell -git add src/features/housekeeping/foundation/contracts -git diff --cached --check -git commit -m "feat: define housekeeping foundation contracts" -``` - -### Task 8: Request-scoped capability context - -**Files:** -- Create: `src/features/housekeeping/foundation/capability-context.ts` -- Create: `src/features/housekeeping/foundation/server-capability-context.ts` -- Test: `src/features/housekeeping/foundation/capability-context.test.ts` -- Test: `src/features/housekeeping/foundation/server-capability-context.test.ts` - -**Interfaces:** -- Consumes: `PermissionSet`, `getAdminContext()`, and Task 7 capability contracts. -- Produces: `createHousekeepingCapabilityContext()`, `satisfiesCapability()`, and cached `getHousekeepingCapabilityContext()`. - -- [ ] **Step 1: Write pure failing capability tests** - -```ts -const actor: HousekeepingActor = { id: 42, username: "operator", rank: 7 }; - -const permissionSet = ( - slugs: readonly string[], - isSuperAdmin = false, -): PermissionSet => { - const granted = new Set(slugs); - const has = (slug: string) => isSuperAdmin || granted.has(slug); - return { - isSuperAdmin, - has, - hasAny: (...requested) => requested.some(has), - hasAll: (...requested) => requested.every(has), - }; -}; - -const superAdminPermissionSet = () => permissionSet([], true); - -it("evaluates any/all requirements from effective permission methods", () => { - const context = createHousekeepingCapabilityContext(actor, permissionSet([ - "admin.users.view", - "admin.tickets.view", - ])); - expect(satisfiesCapability(context, anyCapability("admin.users.view", "admin.logs.view"))).toBe(true); - expect(satisfiesCapability(context, allCapabilities("admin.users.view", "admin.logs.view"))).toBe(false); -}); - -it("keeps the super administrator explicit", () => { - const context = createHousekeepingCapabilityContext(actor, superAdminPermissionSet()); - expect(context.isSuperAdmin).toBe(true); - expect(context.has("unknown.future.slug")).toBe(true); -}); -``` - -- [ ] **Step 2: Confirm the red state** - -Run: `pnpm exec vitest run src/features/housekeeping/foundation/capability-context.test.ts` - -Expected: FAIL because the helper module is missing. - -- [ ] **Step 3: Implement the pure adapter** - -```ts -export function createHousekeepingCapabilityContext( - actor: HousekeepingActor, - permissions: PermissionSet, -): HousekeepingCapabilityContext { - return { - actor, - isSuperAdmin: permissions.isSuperAdmin, - has: (slug) => permissions.has(slug), - hasAny: (...slugs) => permissions.hasAny(...slugs), - hasAll: (...slugs) => permissions.hasAll(...slugs), - }; -} -``` - -`satisfiesCapability()` returns true for super administrators, then delegates to `hasAny` or `hasAll`; an empty requirement is impossible because Task 7 constructors throw. - -- [ ] **Step 4: Write the failing server adapter test** - -Mock `@/lib/permissions` and assert that the adapter calls `getAdminContext()` once, uses the database-refreshed `session.user` actor, and forwards the returned `PermissionSet` without querying the database directly. - -```ts -expect(await getHousekeepingCapabilityContext()).toMatchObject({ - actor: { id: 42, username: "operator", rank: 7 }, - isSuperAdmin: false, -}); -expect(getAdminContext).toHaveBeenCalledTimes(1); -``` - -- [ ] **Step 5: Implement the cached server adapter** - -```ts -export const getHousekeepingCapabilityContext = cache(async () => { - const { session, permissions } = await getAdminContext(); - return createHousekeepingCapabilityContext( - { - id: session.user.id, - username: session.user.username, - rank: session.user.rank, - }, - permissions, - ); -}); -``` - -- [ ] **Step 6: Run authorization contracts and commit** - -Run: - -```powershell -pnpm exec vitest run src/features/housekeeping/foundation/capability-context.test.ts src/features/housekeeping/foundation/server-capability-context.test.ts src/lib/admin/authorization-contract.test.ts src/lib/admin/guard.test.ts -pnpm typecheck -``` - -Expected: PASS; central authorization still contains no fixed rank threshold. - -```powershell -git add src/features/housekeeping/foundation/capability-context.ts src/features/housekeeping/foundation/server-capability-context.ts src/features/housekeeping/foundation/capability-context.test.ts src/features/housekeeping/foundation/server-capability-context.test.ts -git diff --cached --check -git commit -m "feat: add housekeeping capability context" -``` - -### Task 9: Domain registry, manifests, and navigation model - -**Files:** -- Create: `src/features/housekeeping/foundation/registry.ts` -- Create: `src/features/housekeeping/foundation/navigation.ts` -- Create: `src/features/housekeeping/domains/operations/manifest.ts` -- Create: `src/features/housekeeping/domains/people/manifest.ts` -- Create: `src/features/housekeeping/domains/content/manifest.ts` -- Create: `src/features/housekeeping/domains/economy/manifest.ts` -- Create: `src/features/housekeeping/domains/hotel/manifest.ts` -- Create: `src/features/housekeeping/domains/system/manifest.ts` -- Create: `src/features/housekeeping/manifests.ts` -- Modify: `src/messages/en.json: pages` -- Modify: `src/messages/it.json: pages` -- Test: `src/features/housekeeping/foundation/registry.test.ts` -- Test: `src/features/housekeeping/foundation/navigation.test.ts` -- Test: `src/features/housekeeping/foundation/localization-contract.test.ts` - -**Interfaces:** -- Consumes: Task 7 manifests and Task 8 `satisfiesCapability()`. -- Produces: `HOUSEKEEPING_MANIFESTS`, `createHousekeepingRegistry()`, and `buildHousekeepingNavigation()`. - -- [ ] **Step 1: Write failing registry validation tests** - -```ts -const manifest = ( - id: HousekeepingDomainId, - capabilitySlug = PERMS.ADMIN_DASHBOARD, -): HousekeepingDomainManifest => ({ - id, - labelKey: `pages.housekeeping.domains.${id}.title`, - descriptionKey: `pages.housekeeping.domains.${id}.description`, - iconId: "settings", - previewHref: `/admin-next/${id}`, - capability: anyCapability(capabilitySlug), - routes: [], -}); - -const duplicateA = manifest("operations"); -const duplicateB = manifest("operations"); -const duplicateRouteManifest: HousekeepingDomainManifest = { - ...manifest("people"), - routes: [ - { id: "one", labelKey: "one", href: "/admin-next/people/users", capability: anyCapability(PERMS.ADMIN_DASHBOARD) }, - { id: "two", labelKey: "two", href: "/admin-next/people/users", capability: anyCapability(PERMS.ADMIN_DASHBOARD) }, - ], -}; -const unknownPermissionManifest = manifest("system", "admin.ghost.view"); - -it("rejects duplicate domains, routes, and unknown capability slugs", () => { - expect(() => createHousekeepingRegistry([duplicateA, duplicateB])) - .toThrow(/duplicate domain id/); - expect(() => createHousekeepingRegistry([duplicateRouteManifest])) - .toThrow(/duplicate route href/); - expect(() => createHousekeepingRegistry([unknownPermissionManifest])) - .toThrow(/unknown capability slug: admin\.ghost\.view/); -}); - -it("registers exactly the six approved domains", () => { - const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS); - expect(registry.domains.map((domain) => domain.id)).toEqual([ - "operations", "people", "content", "economy", "hotel", "system", - ]); -}); -``` - -- [ ] **Step 2: Confirm the red state** - -Run: `pnpm exec vitest run src/features/housekeeping/foundation/registry.test.ts` - -Expected: FAIL because registry and manifests are missing. - -- [ ] **Step 3: Implement registry validation** - -Build the known permission catalog from `Object.values(PERMS)`. Validate domain IDs, preview hrefs, route IDs, route hrefs, non-empty label keys, and every `CapabilityRequirement` slug. Freeze the returned domain array. - -- [ ] **Step 4: Define the six empty-workflow manifests** - -Each manifest has no migrated business route; only its preview href and domain capability are present. Use these capability groups: - -- operations: `admin.dashboard`; -- people: any user, moderation, ticket, ban, or `mod.*` view capability; -- content: any news, pages, banners, events, polls, or prefixes view capability; -- economy: any catalog or shop view capability; -- hotel: any room, radio, or asset-import capability; -- system: any settings, logs, analytics, DevOps, notifications, permissions, or RCON capability. - -- [ ] **Step 5: Add English and Italian source copy** - -Under `pages.housekeeping`, add `preview`, `navigation`, `domains`, and `states`. Domain keys are `operations`, `people`, `content`, `economy`, `hotel`, and `system`, each with `title` and `description`. Add explicit strings for preview badge, disabled command trigger, back to site, empty foundation state, partial state, error state, and loading state. - -- [ ] **Step 6: Implement and test the navigation projection** - -```ts -export interface HousekeepingNavigationDomain { - id: HousekeepingDomainId; - href: string; - iconId: HousekeepingDomainManifest["iconId"]; - label: string; - description: string; - items: readonly { id: string; href: string; label: string }[]; -} -``` - -`buildHousekeepingNavigation(registry, context, translate)` filters unauthorized domains and routes before translating. Tests must prove a moderator without `admin.dashboard` can see People when a `mod.*` capability is present, while Economy stays hidden. - -- [ ] **Step 7: Verify localization keys** - -The localization contract reads `en.json` and `it.json`, resolves every manifest key by dot path, and fails on missing or non-string values. - -- [ ] **Step 8: Run and commit Task 9** - -Run: - -```powershell -pnpm exec vitest run src/features/housekeeping/foundation/registry.test.ts src/features/housekeeping/foundation/navigation.test.ts src/features/housekeeping/foundation/localization-contract.test.ts -pnpm typecheck -``` - -Expected: PASS; six domains in locked order and all manifest keys present in English and Italian. - -```powershell -git add src/features/housekeeping/foundation/registry.ts src/features/housekeeping/foundation/navigation.ts src/features/housekeeping/domains src/features/housekeeping/manifests.ts src/features/housekeeping/foundation/registry.test.ts src/features/housekeeping/foundation/navigation.test.ts src/features/housekeeping/foundation/localization-contract.test.ts src/messages/en.json src/messages/it.json -git diff --cached --check -git commit -m "feat: add housekeeping domain registry" -``` - -### Task 10: Command Deck shell and standard page states - -**Files:** -- Create: `src/features/housekeeping/foundation/shell/housekeeping-shell.tsx` -- Create: `src/features/housekeeping/foundation/shell/domain-rail.tsx` -- Create: `src/features/housekeeping/foundation/shell/context-nav.tsx` -- Create: `src/features/housekeeping/foundation/shell/command-trigger.tsx` -- Create: `src/features/housekeeping/foundation/shell/operator-summary.tsx` -- Create: `src/features/housekeeping/foundation/page/housekeeping-page-shell.tsx` -- Create: `src/features/housekeeping/foundation/page/housekeeping-page-state.tsx` -- Test: `src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx` -- Test: `src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx` - -**Interfaces:** -- Consumes: `HousekeepingActor`, `HousekeepingDomainId`, and `HousekeepingNavigationDomain`. -- Produces: pure server-renderable shell/page components with no search, mutation, persistence, or database behavior. - -- [ ] **Step 1: Write the failing shell accessibility test** - -```tsx -const navigation: readonly HousekeepingNavigationDomain[] = [{ - id: "people", - href: "/admin-next/people", - iconId: "users", - label: "People", - description: "Users, moderation, and support", - items: [{ id: "users", href: "/admin-next/people/users", label: "Users" }], -}]; - -it("renders the Command Deck landmarks and active domain", () => { - const html = renderToStaticMarkup( - - content - , - ); - expect(html).toContain('href="#housekeeping-content"'); - expect(html).toContain('aria-current="page"'); - expect(html).toContain('id="housekeeping-content"'); - expect(html).toContain("operator"); -}); -``` - -- [ ] **Step 2: Confirm the red state** - -Run: `pnpm exec vitest run src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx` - -Expected: FAIL because the shell components are missing. - -- [ ] **Step 3: Implement the shell components** - -Use `Link`, semantic `
- {error.classname}: {error.message} -
content