The homepage mixed four different translucent washes (surface 44-60% plus a
backdrop-filter each), rendered the same four counters twice, and showed a
dangling "Welcome to " eyebrow because the localized string expected the hotel
name. A missing theme wallpaper replaced the whole sky gradient with flat
white, and stale CMS image references produced broken images and 404s.
Replace the ad-hoc panels with one opaque, theme-derived `.panel` token set
(surface, border, shadow, header, radius) and a `.panel-float` variant for the
counter strip that straddles the hero edge. The strip is now the single home
of the four headline counters; a failed read renders "unavailable" instead of
silently dropping a cell. Add the fourth feature card (Games) that already had
translated copy, link recent users to their profiles, and fall back to themed
placeholders for news covers and camera photos without an upload.
Add `pages.home.heroEyebrow` to all 25 locales, layer the CMS wallpaper over
`--site-bg-gradient` instead of replacing it, and keep the sky in dark mode.
Add `publicAssetExists` so local uploads that no longer exist are skipped
(wallpaper, help topics) rather than rendered broken. Fix the `/help` topic
icons to use the `/assets/images/help-center/` base the detail page already
used.
Throttle the spotlight glow to one update per animation frame, drop the dead
`.glass-chip` rules, define the missing `.panel-float`, and fix the stat
divider and reduced-motion selectors. Stat labels use the readable text token
so they clear WCAG AA, and the language button's accessible name now contains
its visible label.
Verified: tsc, biome, 3590 unit tests, i18n audit unchanged, axe 0 violations
at 390/768/1024/1280/1440 in light and dark, quality e2e 16/16.
Three build-time improvements, all measured on this host.
The build context no longer ships the test suites. `next build` type-checks
the whole project, so `e2e/` and `integration/` in the context made the image
build fail on a type error in test code and pulled @playwright/test, vitest,
msw, testcontainers and drizzle-kit into the image. The CI runs those suites
on the host. Type-check time in the build drops from 2.2s to 1.3s, and a
contract test now fails if application code ever imports from those folders.
The CI now exports the build cache to a directory outside the runner
workspace: /var/cache/epicnext-cms-buildcache for the release and a separate
map for branch preflights, so a branch can never feed the release cache. The
workspace is wiped after every job and `docker builder prune` runs after
every release, so the next build started with an empty cache and downloaded
all 493 packages again (measured: reused 0, downloaded 493). With the
durable export the frozen dependency layer is reused (measured: reused 493,
downloaded 0) even after a full prune. A cache directory that is not
writable only produces a warning; the release is never blocked.
Investigated the remaining ~30s of Next.js build tracing: excluding the
dev-only packages with `outputFileTracingExcludes` made it slower (35s vs
30s), because the traced output was already minimal (5 packages). The cost
is Next's own page-config collection and file tracing over 329 routes, not
the dependency set, so no configuration was kept.
The Docker build was slower than it needed to be and could drift away from
the lockfile. Three separate causes, fixed together.
The image installed a pinned [email protected].1 while package.json pins 12.11.2
through `packageManager`. pnpm silently self-managed the difference at
build time: it downloaded the right version into an uncached location and
never verified the sha512 hash. Install corepack instead and let it resolve
version and hash from the pin, so the image can no longer diverge and a
tampered tarball is rejected. The resolved pnpm lives in COREPACK_HOME
inside the image, so the migrations image still runs pnpm offline
(`docker run --entrypoint pnpm ... db:migrate`).
The pnpm store cache-mount pointed at /root/.local/share/pnpm/store, but
pnpm keeps its store in $PNPM_HOME/store/v11. The mount was a no-op: every
build re-downloaded all 493 packages ("reused 0, downloaded 493"). Mounting
the store where pnpm actually keeps it turns that into "reused 493,
downloaded 0".
The build context carried backups/ (232MB of rotating database dumps) and
public/vendor/ (regenerated by `pnpm assets:editor` from node_modules).
Both grew the context and invalidated the `COPY . .` layer on every daily
backup rotation, forcing a full Next.js rebuild. Excluding them takes the
context from 276MB to 23MB.
Compose now also exports the build cache to .docker-cache/cms and imports it
again, so the cache survives `scripts/docker-prune.sh`, which trims the
daemon cache to 4GB after every deploy. Verified by pruning the entire
daemon cache: the next build restored every layer from the local export and
downloaded nothing.
Also: byparr's floating `:latest` tag is now pulled on every start, the
migrations build in scripts/docker-update.sh imports the same local cache,
the README no longer claims the build needs host networking or an
iptables-disabled daemon, the pnpm rationale in renovate.json reflects that
Docker follows the pin automatically, and contract tests pin the new
invariants so the store path cannot drift back.
On a CI-managed host scripts/ci-deploy.sh owns port 3002 through the
blue/green slots, so a compose replica of the cms service could only ever
crash-loop on EADDRINUSE — it never serves traffic, because nginx points at a
slot container. Gate the service behind the `cms` profile so a bare
`docker compose up -d` starts only byparr. Naming the service explicitly
(`docker compose up -d cms`, which is what the installer and updater already
do) still enables the profile automatically, so the compose-only install path
keeps working.
Keep the one port that is actually served (3002) and make the 2 GB ceiling
hard by capping swap at 2g as well. Drop the green compose service (3003):
the green slot only ever exists as a docker run container created by CI, so
compose no longer defines it and the two paths cannot drift apart.
The loopback override contract stays covered: proxy-config.test.mjs now
renders the configuration with --profile cms, and a new build-contract test
pins the profile and the memory limits so neither can be dropped by accident.
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.