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.
Second review pass covering security, performance, admin tooling and the
public/room flows. All HIGH and MEDIUM findings from the audit are resolved;
nothing in this commit changes the visible feature set.
Authentication & session security
- CSP is now set on the request headers in the proxy, which is what Next.js
uses to derive the render nonce, so the nonce is effective.
- 2FA: an already-enabled user cannot re-enroll, the setup endpoint is
rate-limited per account, and confirmed codes are persisted so the second
secret no longer silently never applies.
- Password reset revokes the ticket, authTicket and all personal access
tokens, and bumps the token version so existing sessions die. The same
revocation is now wired into the staff-side password reset.
- /reset and /verify return a stable error code instead of raw text; the
mail lookups are ordered by id so duplicates cannot vary between runs.
- Resending the verification mail gets a per-address cooldown on top of the
per-user limit.
- Issue API tokens with the narrower radio/ticket ability set instead of "*".
Authorization & input handling
- Mid-rank staff can no longer keep dynamically granted non-view admin.*
permissions: existing grants are revoked by migration and the grant lookup
is restricted to "%.view". Rank guards use the dynamic super-admin check.
- Alerting a user is permission-checked and audited like the other tools.
- Material mutations (giveCredits/giveDuckets/giveDiamonds, the admin user
actions route, bulk user actions) are capped and rank-guarded, and bulk
ids are bounded.
- updateRoom / updateRoomItem write through a field allowlist, and items
may only be edited through their own room.
- Classnames reaching the filesystem are validated before use so a crafted
value cannot escape the asset directories.
- The word filter now also covers offline mails, guild forum threads and
replies, and user mottos.
- Media uploads are validated by magic bytes, /api/media requires the page
edit permission, APP_URL must be configured once mail is enabled, and the
diagnostics error route checks the fetch site header.
Admin tooling
- Secret settings render masked and cannot be overwritten with a blank or
an arbitrary raw key; radio credentials are new password inputs.
- Commandocentrum balance changes are audited.
- Admin list pagination reads the caller's per-page instead of the max, and
the log exporter caps offset and search length.
Performance
- Catalog translations are cached per module, with a cheap revision hash;
the public online count uses a stale window instead of hammering the DB.
- The cache warmup now primes the payload the home route actually reads.
- TopHeader batches its queries into one round trip, and LCP avatars load
eagerly.
- motion/react and sonner are no longer part of the root layout; the nav
dropdown and mobile nav panels are lazy client chunks. Anonymous visitors
again get the navigation chrome, and public pages get an edge cacheable
response.
Accessibility
- Nested <main> elements in phase pages became <section>; the page entrance
and route progress animations are pure CSS that respect reduced motion.
The previous commit made imports update the Studio without a reload, but the
guarantee only held inside the tab that started the import and only as long as
every read succeeded. Four holes were left, and this closes them.
A session that mounted the tree before an import kept the pre-import tree for
the rest of its life, because ensureCatalogTreeLoaded() was a once-per-session
no-op. It now asks the server whether what it holds is still current. The answer
is a revision: sendCatalogUpdate() already runs after every catalog write, so it
bumps one, and clients read it on mount, on focus, on a 20s poll and from other
tabs over a BroadcastChannel. An import that finishes in another tab, another
browser or the job worker now lands here too.
A failed read used to be swallowed, which is the worst outcome available: the
rail kept showing pre-import counts as if they were current and nothing said so.
The snapshot now carries the error, the rail shows it with a retry, and the
previous tree stays on screen because stale beats empty.
Every settled import pulled the entire flat tree, which is the one payload that
grows with the size of the catalog. The revision doubles as the ETag on
mode=full, so an unchanged catalog answers 304 and the poll costs a file read.
An import could also report success for an offer the hotel will never sell: a
hidden or disabled page, an item_ids that misses the furni id, a zero amount.
importSingleFurni reads its own row back and reports each of those as a warning,
where the import report already is, instead of leaving it to surface as "the
import did not work" in the client.
Finally, the catalog items table no longer falls back to router.refresh() —
onRefresh is now required, so every mutation ends in a refresh of the caller's
own data instead of a route re-render that threw away editor state and scroll
position. useServerAction keeps its default, because 47 callers across the app
depend on it. The 750-line CatalogTree in catalog-tree.tsx was dead code that
kept its own stale tree and three more router.refresh() calls; only CatalogIcon
and LAYOUT_COLORS are still imported, so the rest is gone.
Tests: the store now covers revisions, 304s, probe failures and error recovery;
a jsdom test mounts a consumer and asserts the tree updates in place with no
navigation; the old organize-imports e2e asserted nothing about the endpoints
the code actually calls, and is replaced by one that asserts a cross-tab write
lands in the mounted categories without a reload.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Add a gamedata cleanup to the Nitro Cleanup panel: a read-only preview
plus an apply run that dedupes FurnitureData classnames and removes
rows and figure entries that reference nothing.
Three passes run in a fixed order, because cleanFigureMap has to precede
cleanFigureData: dropping the part that points at a set is what makes
that set unreferenced.
A pass refuses to write when it would delete more than maxRemovals rows
(default 500) and reports the reason, a wrong asset directory otherwise
turns every row into an orphan and one call would empty the file. Passes
that would act on empty input (no libraries, no sets) treat that as a
missing file rather than as a reason to delete everything. Every write
copies the file to a timestamped backup first, so a pass that turns out
to be wrong can be undone by hand.
The plan reads FurnitureData once and hands the parsed copy to both
furniture passes; the file is tens of megabytes in a real deployment.
Newly converted .nitro bundles now store their spritesheet as WebP VP8L
instead of PNG, so imports land much smaller without changing a single
pixel. The texture member and spritesheet.meta.image are both labelled
from the actual bytes, never from a caller's assumption.
- encode through sharp with lossless and exact, so colour hidden under
alpha 0 survives; this mirrors ImageSharp's TransparentColorMode.Preserve
- detect PNG/WebP by magic bytes and reject anything the client cannot
render, on create, download and upload paths
- keep the source format when deriving size-32 sheets, scaling composites
and editing metadata, so existing bundles are never silently rewritten
- report fidelity in the studio: the compression panel re-encodes with the
same options the importer uses, so it cannot drift and invent false
warnings, and shows PNG/WebP size estimates
convertSwfToNitro and buildSpritesheet are now async, so the worker, the
main-thread fallback and every import call site await them. PNG stays
supported for existing bundles and icon sidecars are untouched.
resolveNitroFrame now matches spritesheet frame keys that carry a .png
suffix or namespaced naming, and isScale/scaleName preserve that suffix.
Broken source sprites (missing frames or references to icon artwork) are
skipped and reported instead of aborting the whole generation, and the
studio UI surfaces the skipped count.
Interaction detection:
- Trust SWF-derived sit/lay/stand flags only when the logic XML actually
contains action data (new hasActions metadata); otherwise fall back to
keyword detection so custom furni without <action> nodes are still
classified correctly
- Add French/Dutch/German/Spanish/Italian keywords (chaise, banquette,
stoel, silla, sedia, stuhl, tafel, mesa, ...) to sit/lay/stand detection
with token-boundary matching to avoid false positives like bedside_table
- Unify interaction_modes_count priority: mechanic fixed modes, then raw
animation state count, then sit/lay fallback, then keyword default
- Detect mechanic type even when real flags are not used
Catalog integrity:
- Skip duplicate catalog_items inserts in clone and upload imports
- Re-check live catalog rows before applying generated repair SQL
- Add rebuildCatalogOfferIds() which rebuilds every catalog_items.offer_id
from the local FurnitureData.json (matched by entry id, then classname)
and run it after every import path (single, batch, batch-regen, clone)
- importSingleFurni: actually update an existing item instead of failing on a primary key INSERT collision; refuse to reassign a spriteId owned by a different classname
- reconcileFurniDataWithItemsBase: also sync each entry's offerid to the DB sprite id, not just the id
- Move the full-table interaction_modes_count verification out of the per-item import hot path and run it once per batch/single import
- clone-import: apply the auto-detected interaction_type (was hardcoded 'default'), set FurnitureData offerid and catalog_items.offer_id to the new local sprite id (was the source hotel's id / -1)
- clone batch route: run the same post-import reconcile/verify/ownership/RCON consolidation as the furni batch route
- upload-import: set offer_id to the allocated id in both the direct insert and the generated SQL migration (was -1)
- generateCatalogSql: use the classname as catalog_name and the item id as offer_id so rows match the app-managed import convention
- stats + missing-nitro endpoints: match catalog membership via item_ids instead of the catalog_name join, so translated display names no longer break counts
- deleteImportedItem/rollback: delete catalog rows by the canonical item_ids link only
- classifyFurni: wall items always classify as 'walls' before prefix rules (rare_/val_/xmas_) can misfile them
Replace classname/name keyword guessing with real data so sit/lay/stand
has zero false positives:
- autoDetectInteraction() now accepts real flags (cansiton/canlayon/
canstandon), logicType, and animation-state count; real data wins and
keywords are only a fallback when no real data exists.
- New furni-real-interaction helper reads the local FurnitureData.json
(highest-revision entries win; a true flag in any duplicate is kept)
and falls back to the official Habbo furnidata.
- interaction_modes_count now uses emulator conventions: dice=6,
gate/teleport=2, roller=1, generic multistate = animation-state count,
chairs/beds=1, plain items=0.
- verifyAndFixInteractionModesCount only touches items with real data
and skips everything else (no keyword overwrites).
- parseNitroBundle falls back to gunzip for gzip-compressed .nitro files.
- Wired through furni-import, upload-import, clone-import, and the nitro
editor auto-detect button.
- New auto-interaction.ts: detects canSit/canLay/canStand from classname/name keywords
- Maps keywords to interaction types (bench, chair, bed, table, etc.)
- Returns clickLimit and interactionModesCount for proper click/interaction limits
- Nitro editor: added auto-detect button with preview of detected settings
- furni-import.ts: auto-sets interaction_modes_count during import
- upload-import.ts: auto-detects for direct insert and SQL migrations
- clone-import.ts: auto-detects when cloning from other hotels
Add a full-viewport /admin/studio workspace that unifies the import and
catalog systems into a single automated flow:
- Furni library browser against official Habbo furnidata with search,
type/status filters, live previews and pagination
- Catalog structure rail showing where imported furniture lands
- Detail drawer with automatic placement preview (category + price via
classifyFurni/autoPriceFurni) and one-click import that downloads
assets, converts SWF to nitro, writes items_base, updates
FurnitureData.json and creates an auto-priced catalog entry
- Batch auto-import with SSE progress and RCON cache refresh
- Nitro editor integration and imported-item deletion
Extract pure auto-catalog helpers (CATEGORY_PAGE, CLASSNAME_RULES,
classifyFurni, autoPriceFurni) into a client-safe module shared by the
server pipeline and the Studio UI so the preview always matches what the
emulator import applies.
Add 22 test files covering imager, soundtracks, browser-headers, source-keys (figure/pet/effect), effect-source, plus catalog-translations, catalog-layouts, client-translation-files, translations-utils, and various admin/services/helpers modules. Total test count increases by 120+.