Closes the four remaining LOW items.
Search and news archive caching
- A leading-wildcard LIKE cannot use an index, so every /search section cost a
COUNT(*) scan plus an ordered page fetch, and /news did the same for its
archive. Both now cache: search per section for 30s, the archive for 60s
under the existing news revision so publishing an article drops it at once.
- Sections are cached independently, so one slow query cannot hold up the rest
and a failure is not cached as a result.
- The cached value is passed through cacheSafe() so the Redis path and the
in-process path return the same types; without it a cache hit would hand the
events grid a string where a miss hands it a Date, and it calls toISOString()
on that field. Dates are revived on the way out so the public signatures of
loadNewsArchive and loadPublicSearch are unchanged.
- Archive entries are keyed on the REQUESTED page rather than the clamped one,
so two requests that clamp onto the same page cannot alias each other.
motion/react out of the public bundle
- Converted the six public-facing users: the radio player, the typewriter text
(a motion.span with no animation props at all), the photo lightbox, the
animated counter, the footer CMS-info popup and the scroll reveal. That was
the actual entry points — the counter and the popup reach the public home page
and footer through static imports, so removing only the three originally named
would have left the library in the bundle anyway.
- Each animation moved to a CSS class, and the two that animate on exit now hold
the element for the length of the fade, which is what AnimatePresence used to
do.
- motion/react now only ships with /admin and the two already-lazy nav panels.
- Two safety fixes came out of this: the scroll reveal starts at opacity 0, so
it is forced visible under prefers-reduced-motion and via a <noscript> rule in
the root layout; and it now emits the .motion-reveal class, which the theme
panel's "Scroll Reveal" toggle selects and which previously matched nothing.
- The CMS-info backdrop became a real button in a pointer-transparent layer
instead of a handler on a static element, so click-outside-to-dismiss is
reachable by keyboard.
Fewer duplicate router refreshes
- Next.js re-renders the current route as part of a server action's own response
when that action revalidates, and applies it with a seeded navigation; the
router only skips its own update when the action did NOT revalidate. So the
refresh after such an action fetched the same tree twice.
- useServerAction takes an opt-in `revalidated` flag that skips it. It is opt-in
per call rather than derived from an action name, since a rename would
silently change behaviour. Applied to the two user-facing call sites whose
actions were verified to revalidate their own route.
Touch targets
- .btn was the one shared control at 40px; it and the lightbox and CMS-info
close buttons are now 44px, as is the password toggle (the auth input already
reserved 44px for it). The remaining 32px icon buttons pass WCAG 2.2 AA, which
only asks for 24px; enlarging those inside inputs and overlays was left alone
because it risks visual breakage that cannot be checked from here.
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.
Wrap next/link in a shared Link component that ships prefetch=false by
default, so no route is ever prefetched (viewport or hover) anymore, and
drop the DNS prefetch hint. Removes hidden background requests that were
the source of intermittent issues.
Remove the per-route 'export const instant = false' opt-outs now that the root layout carries the single Cache Components opt-out. Child pages inherit the opt-out, so admin/mod/radio leaf pages that only access cached or DB data stay instant while runtime-dependent pages remain dynamic. Update the staff-smoke contract test to assert the root-layout contract.
- Clone import: defer FurnitureData.json writes and append all entries in a
single batched write instead of one read-modify-write per item, removing
the main serialization bottleneck for large batches.
- Clone import: raise SSE batch concurrency cap from 5 to 10 and bump the
clone client/route default from 2 to 6.
- Add a flush hook to runSseBatch so callers can batch deferred work before
batch_complete is emitted, and surface flush errors as an error event.
- Enable Next.js Cache Components (instant: false opt-out) and silence the
related build warnings in next.config.ts.
- Switch isomorphic-dompurify to dompurify and refresh dependencies.