Use the shared LiveOnlineCount so the landing top bar reads "0 <hotel> online"
instead of a bare number, and drop the hotel name from the left (the logo
stays) so it is not shown twice.
The index, login and register render their own AuthTopBar, so the shared
TopHeader / SiteHeader banner / Navigation duplicated the same chrome. Hide the
global chrome on those three standalone landing pages only.
The homepage duplicated the online count and recent-user avatars (hero plus
community sidebar), and the public pages each kept their own chrome. Put one
AuthTopBar at the top of index, login and register — logo + hotel name plus a
live online indicator and the language/clock/theme controls — and drop the
duplicated hero rows.
Add PASSWORD_MIN_LENGTH / PASSWORD_MAX_LENGTH (validated in src/env.ts) and a
shared passwordPolicy helper. The policy drives the zod schemas in register,
password reset and the admin create-user dialog, plus the live requirement
checks and strength meter in the register form.
Locale messages now interpolate the configured minimum via {min} instead of
hard-coding 12.
The login and register pages were near-identical after the previous cleanup,
and both forms repeated the same labelled, icon-prefixed input seven times.
Move the data loading and page shell into auth-landing.tsx (loadAuthLanding +
AuthLandingView) and the input markup into a single AuthField, so the pages
become thin compositions and every field shares one focus, icon and aria
implementation. The numbered section headers collapse into AuthSectionHeader.
No behaviour change.
Login and register each carried their own copy of the promo panel and the
error banner, so the two pages drifted apart and every caller had to wire
role="alert" by hand. Extract a shared AuthPromoCard and AuthAlert, use them
in both forms and for the login notices, and link errors to their fields via
aria-invalid / aria-describedby.
Focus now lands on the 2FA code field the moment it appears, the credential
fields lock while a request is in flight, and invalid inputs get a danger
tint. The homepage top bar was a byte-for-byte duplicate of AuthTopBar; it
now renders the shared component, and the compact homepage login uses the
tighter spacing its comment already promised.
The project lints with Biome only, so none of these 78 directives across 57
files ever suppressed anything. Directives that carried the rationale for a
flagged dynamic index access keep that rationale as a plain comment.
/ is the highest-traffic route but was missing from both the public-route
instrumentation and the JS size budgets, so a payload regression there could
never fail the build. It is now wrapped in withPublicPagePerformance and
budgeted alongside the other public routes.
optimizePackageImports listed date-fns, which is not a dependency (a no-op),
while omitting the barrel-heavy packages that are actually imported: motion,
@dnd-kit/core, @dnd-kit/sortable and @tanstack/react-virtual.
The root layout fed the entire ~254 KB message catalogue to
NextIntlClientProvider, which serializes it into the RSC payload of every
page. A public page only reads ~59 KB of it; the ~195 KB difference is staff
tool copy that only /admin, /admin-next and /mod ever render.
The root provider now installs the reduced catalogue and each staff route
group re-installs the full one, since a nested NextIntlClientProvider replaces
the parent message set rather than merging into it. pages.admin.actions stays
public because the shared useServerAction hook runs on public pages too.
Measured: 253 KB -> 59 KB of messages per public page (raw JSON).
Guard tests cover all three halves: the root keeps using the reduced
catalogue, every staff layout installs its own full provider, and no public
tree reads a staff-only namespace.
A single authenticated render called auth() 4-5 times (access guard, site
layout, both permission loaders, the page itself), and every call re-parsed
the session cookie, decoded and HMAC-verified the JWT and ran the jwt/session
callbacks. Wrapping NextAuth's handler in React's cache scopes that work to
one verification per request.
Outside a render (scripts, jobs worker) React's cache is a pass-through, so
those call sites behave exactly as before.
Adds the header.* keys introduced by the header rewrite (enter, search
users, staff, mod/admin panel, command center, logs, logo generator,
dashboard/profile/settings links, credits/duckets/diamonds, ...) to all
25 message files. Values are translated where available and fall back to
English otherwise.
The working tree files had been re-serialized with escaped unicode
(\uXXXX); this commit restores literal UTF-8 so the diff shows only the
content change instead of a full-file rewrite.
The production webpack build failed with UnhandledSchemeError: client
components imported @/lib/logger, which binds pino, AsyncLocalStorage
(node:async_hooks), node:crypto and node:fs — and validates server env on
import. The UI fixture aliased those modules, so the harness and tests
never hit it.
- add src/lib/browser-log.ts: browser-safe logger with the same interface;
errors are forwarded to /api/diagnostics/errors via reportBrowserError
- guard src/lib/logger.ts with server-only and drop its now-dead browser
branches
- switch the 11 client components/hooks to @/lib/browser-log
Verified with the Dockerfile's production build (pnpm run build) and the
full vitest suite.
- Replace all 39 console.* calls with universal logger (pino server, console browser)
- Set server-only marker on import-badge.ts
- Disable DB query logging in production
- Add bulkInsertCatalogItems: one transaction instead of N per-item inserts
- Add reserveCatalogItemIds helper for bulk ID allocation
- Optimize translateCatalogItems: single inArray batch query instead of N per-item selects
- Test mock updated for logger.debug
- biome clean, tsc clean, 3569 tests passing
Updating the catalog was only possible from the admin "Catalog audit" page,
which streams `runCatalogAudit` over SSE and always compares against every
enabled clone source. This adds the same engine to the terminal so it can be run
by hand or from cron:
- `scripts/catalog-sync.ts` — CLI driver around `runCatalogAudit`. Prints the
live progress, the per-source "on the source but not in this hotel" counts and
a summary block; generated catalog SQL is saved to
`storage/catalog-sync/*.sql` (gitignored). Supports `--list-sources`,
`--sources=<a,b>`, `--icons`, `--nitros`, `--furnidata`, `--structure`,
`--check-ids`, `--sql`, `--apply-sql`, `--organize-sql`, `--all`, `--json` and
`--fail-on-errors`.
- `scripts/catalog-sync.sh` — interactive menu (`pnpm catalog:sync`). Lists the
configured sources (so the menu always matches `clone_sources`), lets Habbo
(COM) be taken with Enter, and offers audit-only through full repair +
applying the catalog SQL, each with a distinct confirmation. With no arguments
it needs a terminal; with any flag it passes straight through to the CLI.
- `runCatalogAudit` gains `sourceIds` to restrict which sites are compared;
omitting it keeps the previous "compare against everything enabled" behaviour.
An audit writes nothing, and `--apply-sql` is the only flag that changes the
catalog. Verified against the live DB: 16370 items_base rows, 16479 catalog
items, 2871 Habbo (COM) entries missing from this hotel.
Legacy accounts can carry a malformed stored look — the old register default
`hr-100-.hd-180-1...` has an empty colour slot — which the imager rejects with
400 "Invalid figure format", so the avatar renders broken everywhere the look
appears. Normalise the figure in the shared URL builder (`getAvatarUrl`, used by
every avatar on the site): an unrenderable look becomes `DEFAULT_AVATAR_FIGURE`
while the proxy route keeps rejecting malformed input with 400.
The rule set and default now live in one client-safe module
(`src/lib/avatar-figure.ts`), shared by the URL builder and the server parser.
`src/lib/furni/classname.ts` is imported by client components for its pure
string helpers, but it read `process.env.FURNI_BUNDLE_EXT` at module load.
In any client bundle without a `process` shim — including the UI fixture's
esbuild build — that threw `ReferenceError: process is not defined`, so the
lazily-loaded organize-imports dialog never mounted and `pnpm test:ui`
failed (studio.spec.ts). Fall back to the default extension when `process`
is absent; server behaviour is unchanged.
The starter look for a new account was `hr-100-.hd-180-1...`, whose empty
hair colour slot is rejected by the imager with 400 "Invalid figure format".
Every player who registered without a look therefore got a broken avatar.
Add the missing colour slot (`hr-100-1`), which the imager renders.
- catalog: apply bulk offer edits in a single transaction with one RCON
refresh instead of N round-trips, virtualize the items table past 150
rows and cap the shop preview at 200 tiles
- cache: lift the public cache TTLs and the boot warm-up into env-tunable
constants (src/lib/perf-ttls.ts; CMS_*_TTL_SEC, CMS_WARMUP_ENABLED)
- auth: resolve the captcha provider, site key and secret from
CAPTCHA_PROVIDER, TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY when the
admin settings are unset; an explicit DB value (including "none") still
wins, so Turnstile can be enabled per-deploy without touching the DB.
Commit 3c8abb6f made docker-update.sh exit 0 (SKIP) on a host owned by CI so
the cron does not report a failed deployment. The tests still expected a
nonzero exit for the ci-active/ci-green/ci-upstream scenarios, so five cases
failed.
- docker-update.test.ts: move the CI scenarios out of the non-zero group and
assert the skip: exit 0, 'This host is managed by CI' in the output and no
'compose up'. All three CI scenarios (both slot containers and the nginx
upstream) now share one test.
- README.md: document the exit-0 skip.
Bundle the remaining imaging hardening and the host/CI fixes from the
"Doe alles" pass into one release.
Furniture:
- Resolve the local bundle extension from FURNI_BUNDLE_EXT (".hab" default,
".nitro" for a stock Nitro client). The configured extension is written by
imports/repairs and preferred on lookup; the other stays a legacy fallback.
Imaging:
- Avatar proxy sends s-maxage=86400 + stale-while-revalidate (300/3600 for the
fallback path) so the edge can hold a render a day.
- Cloudflare cache rule now covers /api/imaging/ (verified MISS->HIT live).
- Document that bare /imaging is shadowed by the edge proxy to the renderer.
Ops/CI:
- docker-update.sh skips a CI-managed host (exit 0) instead of failing cron.
- ci-deploy.sh runs the cross-browser imaging gate (chromium+firefox+webkit)
against the candidate before cutover; DEPLOY_SKIP_CROSS_BROWSER=1 as escape.
- scripts/imager-health-alert.sh alerts (Discord) on imager up/down transitions;
runs from cron every 5 minutes.
Tests:
- Furniture bundle-ext resolver + FURNI_BUNDLE_EXT wiring.
- Imaging performance budget (bytes + warm latency).
- classifyDdos: /api/imaging/* stays in the api bucket.
The hotel moved from .nitro to .hab bundles, so the imager must treat .hab
like .nitro and the public avatar endpoints must accept the figure strings
the client sends (including extra trailing segments such as `.0`, which the
old FIGURE_RE rejected with a 400).
- imager-upstream: relax FIGURE_RE, extend parseAvatarQuery test coverage
- api/imaging/avatar + /imaging: shared validation, placeholder and ETag/304
- api/imaging/badge: single-flight upstream fetch and conditional responses
- imager-http/imager-png: strong ETag helpers and APNG passthrough guard
- csp + next/image: unoptimized external images, tightened CSP
- deps: override postcss-selector-parser to ^7.1.6
- also fold in the pending drizzle draft snapshot and stray test cleanup
Route all Catalog Studio strings through the admin.studio namespace
(workspace, filters, review, inspector, quality, sync, scale32, organize
imports and page titles/subtitles) with English fallback, and add shared
StudioPage chrome plus a loading skeleton. Tighten mobile padding on the
header and sub-bars.
The badge GIF files exist at /var/www/Gamedata/c_images/album1584/ but the
/api/imaging/badge endpoint only looked in /swf/c_images/album1584/ (relative
to process.cwd()). Added GEDATA_IMAGES_BASE candidate and fetchCandidate
handler so badges render correctly in the admin UI and on the site.
The Studio furniture pane was present in the DOM the whole time and still
looked empty. Every row existed, had real dimensions, and its image loaded
with a 200 — and none of it was visible.
The pane was wrapped in a `motion/react-m` element declared with
`initial={{ opacity: 0 }}` and `animate={{ opacity: 1 }}`. That minimal entry
renders the element but never runs the animation, so the inline style stayed at
opacity 0 and the content was painted transparently forever. Measured on the
live release: the table sat at opacity 1 directly inside a wrapper pinned at
`style="opacity: 0"`.
It went unnoticed because playwright.ui.config.ts sets reducedMotion to
"reduce", under which the animation is skipped and the element lands straight
on its final value. The existing Studio specs therefore passed while the real
browser showed nothing. That also means the perf win from the minimal entry
was never actually delivering a working fade — it only hid the breakage.
The decorative 150ms fade is now a CSS animation (.studio-list-fade-in). A CSS
animation cannot strand content this way: if it never runs, the element is
simply opaque. motion/react-m had exactly one usage in the app and is gone;
the four files that use the full motion/react are untouched and unaffected.
Adds e2e/ui/studio-visibility.spec.ts, which opts out of reduced motion and
asserts no ancestor of a furniture row is faded below 0.9. Verified it fails
on the old code with `Received: 0` and passes on the new, so this cannot
regress silently again.
The previous commit stopped the worker from caching build output, which
removed the cause of the blank Catalog Studio but left the worker itself
serving a purpose worth one offline fallback: three public endpoints
(/api/home, /api/online, /api/radio/config). Behind Cloudflare, for a site
whose visitors are online, that fallback rarely fires and goes stale exactly
where it is most likely to matter.
So the worker goes away rather than staying as a mostly-inert layer that every
future release still has to keep correct.
public/sw.js is not deleted, because deleting it would leave every existing
registration alive and still in control of the page, caching as it did before.
It becomes the opposite of what it was: on activate it deletes every cache,
unregisters itself and reloads open clients so they stop being controlled.
Browsers that already installed a worker therefore uninstall it on their next
visit; browsers that never had one are unaffected.
src/components/pwa-register.tsx is removed and unmounted from the root
layout, so nothing registers a worker any more and the kill switch only ever
runs for the visitors who need it.
The manifest is deliberately untouched. src/app/manifest.ts is an ordinary
Next.js route, independent of any worker, and Chrome installs from a manifest
alone, so the site stays installable on a phone.
Verified nothing depended on it: no other navigator.serviceWorker or caches.*
reference exists in the app, the theme runs from the plain /scripts/
theme-init.js script, and no Studio, catalog or import module touches a
worker. Typecheck and lint clean, 2326 unit tests and 72 UI tests green,
including every Studio and catalog spec.
The Catalog Studio rendered as a blank white page after a deploy, while the
server was serving it correctly: /admin/studio/furni answered 200 with 110KB
of HTML and every API it calls answered 200 with data. No script chunk 404ed
during the session and no JavaScript threw, so the failure was entirely in
what the browser chose to execute.
The service worker wrapped /assets/ and /_next/static/ in a Cache Storage
entry served cache-first, under a hardcoded name (atom-v3) with no build id
and no revalidation. A release therefore could not invalidate it: the browser
kept replaying the previous release's chunks against the new HTML, and React
never hydrated. The chunk branch also had no .catch(), unlike the API branch
below it, so a rejected fetch silently dropped the <script> instead of
surfacing a network error the browser could retry.
That cache also bought nothing. nginx already sends /_next/static/ and
/assets/ as `max-age=31536000, immutable`, and those filenames are
content-hashed, so the HTTP cache is both sufficient and safe — a changed
file gets a new name. The worker was the only layer able to go stale across a
deploy, and it was the one doing it.
Both prefixes now fall through to the network and are left to the HTTP cache.
The API offline fallback and network-first navigations are unchanged, and the
cache names move to v4 so an existing worker is replaced. SW_VERSION moves
with them: it is part of the registration URL, so without the bump the browser
never refetches sw.js and the old worker keeps running.
Unrelated but confirmed while tracing this: /assets/images/themes/arctic-ice.png
is requested by the browser and 404s, but that string exists nowhere in the
code, the database or the build. It was a stale reference served out of this
same cache, not a missing asset.
Every write path normalises a bundle's texture to WebP Lossless, so the
catalog icon was being read back with a PNG-only decoder. decodePng throws
on anything that is not a PNG, the callers caught that and returned null,
and the user-visible result was "no icon (not in source or bundle)" for
every furniture whose source does not serve a standalone icon.
Measured against the production asset tree, all 18,505 bundles were WebP;
icon extraction succeeded on 0 of them. src/lib/services/imager/
decode-texture.ts keeps PNG on the dependency-free decoder and routes WebP
through sharp, which is already a dependency and already encodes these
textures. extractFurniIconPng and getPetIconPng become async; the five
call sites (upload, clone import, furni import, icon repair and both icon
routes) already awaited their surrounding work.
Same root cause, second bug: the spritesheet frame key. Converters disagree
on packing — some keep a trailing ".png", and some lowercase the whole key
while leaving the bundle name mixed-case, so "LTD_fashionistaf" looks up
frame "LTD_fashionistaf_LTD_fashionistaf_icon_a" and never finds
"ltd_fashionistaf_ltd_fashionistaf_icon_a". Any mixed-case classname could
therefore never match, which is most of the catalogue. findFrame tries the
two exact spellings, then falls back to one case-insensitive pass.
Extraction now succeeds on 18,483 of 18,505 bundles (99.88%); the 22
remainder are data, not code — 9 bundles ship no icon asset, 11 do not
parse.
Third: three catalogue icons exist only as .gif while catalogueIconUrl
hardcoded .png, so the picker offered icons that could only ever 404, and
291 icons that ship as both formats were listed twice. The API now dedupes
per id and the two renderers retry with .gif before falling back to the
placeholder, matching what catalog-image-picker already did. Verified live:
/gamedata/.../icon_1542.png returns 404 while icon_1542.gif returns 200.
Separately, close the last hole in the memory cap. Every script in
package.json routes through scripts/with-memory-cap.sh, but invoking the
builder directly — from a terminal, an IDE or an agent — skipped the
wrapper and ran unbounded, on a host with no swap where the OOM killer
picks its victim across the whole machine. next.config.ts now refuses a
production build that the wrapper has not marked, before anything
allocates. next dev and next start are deliberately unaffected.
README gains a Memory-capped commands section covering the per-script
ceilings, the backends and the ulimit -v trap, and its stale version and
script tables are corrected.
The clean-tree guard in ci-deploy.sh aborts the release when
`git status --porcelain` prints anything. The harness stubs `git` as a
shell function that only special-cases `rev-parse`; every other
subcommand fell through to its `ls-remote`-shaped printf, so `git status`
emitted a fake refs/heads/main line and the guard failed on every
scenario.
Introduced in fdb7af7e, which added the guard without teaching the
harness about it, so all 21 deploy tests have been failing since. This
stubs `status` to report a clean tree, and adds a scenario that asserts
the guard actually stops a release before it builds, migrates or starts
anything — the guard itself had no coverage, which is how it could break
silently in the first place.
Every bundle the CMS writes — upload, clone, sync, repair and the pet /
effect / figure importers — now lands as `<classname>.hab`, the extension
this deployment's renderer asks for. `.hab` and `.nitro` are the same
container, so an upload of either extension is accepted.
Resolution goes through one module, src/lib/furni/bundle-file.ts, so
nothing has to know the extension twice. Every existence check probes
`.hab` first and falls back to `.nitro`: the on-disk asset set is still
predominantly `.nitro`, and without the fallback Studio would report every
imported item as missing and the cleanup scan would classify 18k live
bundles as fake leftovers. Downloads are unchanged — Habbo's CDN and every
configured clone source still serve `.nitro`, so the conversion happens on
write, not on request.
Deliberately unchanged: the staged-attachment store in furni-attachment.ts
keys on a UUID and never reaches the client, so renaming it would break
in-flight recovery jobs.
Adds scripts/migrate-nitro-to-hab.ts to rename the existing asset set. It
refuses to run without --dry-run or --yes, never overwrites an existing
.hab, never deletes, and is idempotent.
Note: renderer-config.json lives outside this repo and was patched to
.hab separately; that file is served with a 30-day max-age, so returning
clients need a cms-client cache purge to pick the change up.
Docker 29.1.3 ships BuildKit v0.26, which refuses to grant a build host
networking unless each caller passes --allow=network.host. All three rebuild
paths asked for it, and `docker compose build` has no flag to grant it, so a
rebuild failed immediately with "additional privileges requested". The live
container was never replaced, which is exactly the reported symptom: the site
kept serving the previous release after a rebuild.
Nothing in the build actually needs host networking. It uses the network only
for apk, pnpm and next/font/google — all outbound internet, which the default
bridge provides. Verified by building both the full runner image and the
migrations stage with --no-cache after dropping the flag.
Runtime `network_mode: host` stays: blue/green needs per-release host ports
(3002/3003) and nginx reaches each slot over 127.0.0.1.
The second gap is how a rebuild could still ship the wrong code. ci-deploy.sh
stamped every image with HEAD's revision label, and verify-deployed-release.mjs
only re-checks that same label, so a dirty working tree produced an image that
claimed to be release $sha while containing uncommitted code. docker-update.sh
already refused this; ci-deploy.sh now does too, before any build work.
The UI screenshot suite caught a regression from the previous commit: the admin
article form grew 3px and its baseline no longer matched.
The cause was the blanket `.btn` min-height bump from 40px to 44px. That was
the wrong way round — 40px already clears WCAG 2.2 AA, which asks for 24px, and
44px is a touch-target guideline. Growing every button for mouse users only made
each admin dialog and table 4px taller for no benefit.
The 44px floor now sits behind `@media (pointer: coarse)`, so finger input gets
the comfortable target and desktop keeps its density. A hybrid laptop still
uses the desktop metrics for its trackpad, which is the behaviour the previous
attempt got wrong in both directions.