Ctrl K
ComunidadGuías…

docs/CHANGELOG.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Changelog

All notable changes to the LobbyForge monorepo skeleton.

## [0.2.0-rc.12] - 2026-10-16

rc.11 plus the two entries below: "Handles, pronouns, profile colours, mentions and live members" and "Fixes from the profile-fields e2e run". Update with `lfctl update apply`. Migration 0050 is expand-only, and the ws-gateway must restart with the web image (see the upgrade notes below).

- **Profiles:** a handle (`@name`), pronouns and a profile colour. The nickname is now set per community.
- **Mentions know who they name:** they show as chips, a message that names you is highlighted, and the channel gets a badge plus a desktop notification. The `@` list finds handles.
- **A live lobby:** joins, departures, kicks, bans, names, avatars and roles arrive without a reload, and so do new DMs, with an unread dot. Changes made just after a page opened, or while the connection was down, are caught up too.
- **Moderation:**
  - Bans can have a length, and the Members page lists them with "Lift ban".
  - A timed-out moderator can no longer manage roles, channels, invites or join requests.
- **Chat:**
  - "Load older messages".
  - Deleting asks first.
  - The notification settings work.
  - Refusals are said in your language.
  - Picking a mention no longer moves the letters typed right after it.
- **Fixes:**
  - A malformed WebSocket frame could crash the realtime gateway before sign-in.
  - Signed-out tabs reconnected every 500 ms.
  - Paging skipped rows.
  - Plus the rest of the three-review bug hunt below.
- **Desktop:** the tray has "Switch instance…", and global shortcuts are opt-in.
- **Ops:** backups include the installed marketplace bundles.

## [Unreleased] - Fixes from the profile-fields e2e run - 2026-10-16

The new `e2e/profile-fields-live.spec.ts` ran against a local compose stack (7/7, twice). Getting there found these:

- **Changes made just after a page opened were lost until a reload.** The gateway replays nothing, so an event published between the server render and the subscription never arrived: a member who renamed themselves a moment after you opened the lobby stayed under the old name. `RealtimeClient.subscribe` now takes `onLive`, called when the gateway confirms the topic on a socket (first, and after every reconnect) and at once for a subscriber joining a live topic. The member rows, the DM list, an open channel or DM, and the activity panel re-read there (docs/REALTIME.md, docs/PROFILES.md "Going live").
- **DM unread dots are no longer lost with a missed event.** Each read of the DM list after the first compares with the one before, so a conversation that moved with the other person's message newest is marked: DMs that came in while the laptop slept get their dot too. `GET /api/dm` answers `lastMessageFromOther` per conversation.
- **A picked mention could be stored under the member's handle.** When the same message also had `@handle` and the handle was longer than the name, `@Ivy Novak` stayed plain text. The server now keeps one entry per member and spelling, so each spelling is a chip; the notification still goes once.
- **Typing straight on after picking a mention moved letters.** The caret was placed a frame later and jumped back over what was typed meanwhile, so "@Ivy Novak and" came out as "@Ivy Novak nd…a". It's now placed as the value is committed.
- **The `@` suggestions are a list of options to screen readers** (`listbox` / `option`, `aria-selected` follows the arrow keys).

## [Unreleased] - Handles, pronouns, profile colours, mentions and live members - 2026-10-15

- **Profile fields** (migration 0050, docs/PROFILES.md "Profile fields"):
  - A handle (`@ada_l`): unique, 2–32 of `a-z 0-9 _ .`, checked as you type, reserved names refused, none for guests.
  - Pronouns, up to 40 characters.
  - A profile colour: swatches, a free picker or Automatic. It colours the card's band when there's no banner, and draws a ring around the photo.
  - The card shows `@handle · pronouns` under the names. Pronouns and the colour follow the profile's visibility like the bio; the handle shows like a name.
  - Settings → Profile has a live preview. Every refusal is said in the account's language.
- **Community nicknames:** the settings page lists every community and edits the one picked. "Edit profile" opens it on the card's community. The nickname used to go to the account's newest community, whatever lobby it was set from.
- **Mentions that know who they name** (docs/PROFILES.md "Mentions"):
  - The server keeps `metadata.mentions` (`{ userId, text }`): picks from the `@` list that are members and whose name is still in the text, plus typed `@handles`. Message text stays plain.
  - Mentions render as chips that open the profile card. A message that names you, or `@everyone`, is highlighted.
  - The `@` list matches handles and folds Turkish İ/I.
  - A mention elsewhere in the community puts a red badge on that channel, and a hidden tab gets a desktop notification. Only members who can read the channel are told, never someone who blocked the author, and the text is never sent.
- **Notification settings that work:** "Unread badge", "Suppress while in voice" and the sound choice (default chime, soft pop, none) were saved but never used.
- **Live members** (new gateway topic `members:{serverId}`; docs/PROFILES.md "Live updates"):
  - Joins, departures, kicks and bans show live, as do new names, avatars, handles, nicknames and roles, and edited roles' colours and order. This covers the member list, profile cards, chat author names, the mention list and the voice roster.
  - Events say who changed, never what; each lobby re-reads those rows over REST, projected for that viewer.
- **Live DM list:** a conversation someone else starts appears without a reload, with an unread dot until opened. Starting one from a profile card adds it too.
- **Voice:** the voice list and the call view show profile photos instead of initials.
- **Chat:**
  - "Load older messages": a channel used to stop at its newest 50 messages.
  - After a dropped connection, the channel and the open DM catch up: missed messages appear, deleted ones go.
  - Delete asks first; the message actions show on keyboard focus and on touch screens.
  - A refused edit, delete or pin says why (a Moderation Bot rule, a timeout, `@everyone` without permission), and a refused edit stays open.
  - The composer says refusals in your language, never the server's English.
  - "is typing…" sits under the newest message (it was above the oldest).
  - Pinning no longer marks a message "(edited)".
  - Unblocking someone shows their messages again without a reload.
  - Times and day separators use your time zone from the first paint.
- **Bans:**
  - The admin Members page lists bans (reason, since, until) with "Lift ban". There was no way to undo a ban in the product.
  - The profile card's Ban takes a duration (1 hour, 1 day, 1 week, 30 days or permanent).
- **Smaller fixes:**
  - The channel header shows the channel's real topic, or nothing (it was the same canned sentence everywhere), and long names truncate at 390 px.
  - The fake unread dot on channels is gone.
  - The call view says "Reconnecting…" while LiveKit reconnects.
  - Switching DMs can no longer show the previous conversation or send its draft to the new person.
  - The member-list block button is never an invisible tap target on touch screens.
  - A failed block or unblock says so.
  - Password-change, session-revoke and voice-mute errors are translated.
  - Header icon buttons have names, and the dead footer button is gone.
  - Changing the stream quality no longer leaves an unhandled error.
- **Bug hunt (three read-only reviews: API, lobby UI, realtime/desktop/ops; every finding was checked and fixed with a test):**
  - **Realtime gateway:**
    - **Crash (high):** a single malformed WebSocket frame (bad RSV bits, too large, unmasked, bad UTF-8) could crash the gateway when it arrived with the handshake or during the session check. The socket's error listener is now attached before anything else.
    - **Activity order:** updates for one session reach each viewer in the order they happened, each with the revision of the state it shows. The panel ignores an older one.
    - **Stale subscriptions:** leaving a topic while its subscription was still being checked no longer leaves it live.
    - **Redis outages:** a SUBSCRIBE refused while Redis was down is retried when Redis is back.
    - **Heartbeat:** browsers get a `heartbeat` frame with every ping, so quiet connections are no longer dropped and reopened every minute.
  - **Realtime client:**
    - A signed-out, invalid or revoked session no longer reconnects every 500 ms forever. The client stops after a 4401/1008 close and tells the page through `onTerminalClose`.
    - Backoff resets only once the socket is signed in.
    - Subscriptions refused for a passing reason are retried a few times; a revoked one is asked for once more.
  - **Moderation:** a timed-out moderator kept every management power outside the moderation gate. The following now answer `403 actor_timed_out` (with `until`) through one shared check (`lib/actor-timeout.ts`); the owner is never refused, and the admin screens and chat say it in English and Turkish:
    - approving or rejecting join requests;
    - creating, editing and deleting roles and channels;
    - deleting another member's message;
    - closing another member's poll;
    - revoking another member's invite;
    - changing the access policy, voice settings, banner, apps, bots and webhooks.
  - **Voice:**
    - A voice-settings change reaches everyone already in voice at once, and is audited as `server.voice_settings.update`.
    - Reconnecting to a full room (or at the camera or screen-share cap) no longer counts your own earlier connection.
  - **Invites:** Manage Community lists and revokes every invite without Create Invite.
  - **Channels:** a refused edit no longer saves the name, topic or position first; positions are capped at 1,000,000.
  - **Bans:** banning someone already banned updates the ban (moderator, expiry, reason) and answers 200, audited as `ban.update` with the previous values. It used to answer 201 while nothing changed, so a temporary ban could not be made permanent.
  - **Email verification:** a note sent with an invite redeem needs a verified email in `required` mode, like "Ask to join".
  - **Direct messages:**
    - An upper-case user id no longer opens a second conversation, or one with yourself.
    - Two first messages sent at once no longer fail.
    - A junk `limit` or `before` answers 400, and a non-UUID channel id 404 (both were 500).
  - **Typing indicator:** shows only members who may post there and are not timed out, under their community nickname.
  - **Pagination:** paging back through messages, DMs and the audit log no longer skips rows in the cursor's millisecond.
  - **Messages:** a reply to an unknown or foreign message answers `400 invalid_reply` (was 500).
  - **lfctl:**
    - `backup create` finds the database URL like `update apply` (`--database-url`, `DATABASE_URL`, `.env.prod`).
    - `update apply` without `--yes` no longer writes a full dump first.
    - Backups now include the installed marketplace bundles (`<dump>.plugins.tar.gz`), and `backup restore` puts them back.
    - docs/UPDATES.md lists what a backup does not hold, and recommends rotating `LOBBYFORGE_SESSION_SECRET` after restoring onto a new host.
  - **Desktop:**
    - The tray has "Switch instance…", even for an instance that no longer loads.
    - Global shortcuts are opt-in ("Push to talk anywhere" in the tray), so Ctrl+Space no longer takes the input-language switch from other apps.
    - Not done: "Continue with Google" in the desktop app, which needs a browser-to-app handoff for OAuth sign-ins.
- **Upgrade notes:** migration 0050 is expand-only and idempotent: three nullable `users` columns plus constraints. Restart the ws-gateway with the web image, because the new topic and frames need both. Desktop users who relied on the global shortcuts turn "Push to talk anywhere" on in the tray.

## [0.2.0-rc.11] - 2026-10-13

rc.10 plus the two entries below: "A Discord-like profile card" and "The A-to-Z e2e run: fixes and three new specs". Update with `lfctl update apply`.

- **Profiles:**
  - Click anyone to open a Discord-like card. It has a banner, the chosen status, a photo viewer, About me, member-since dates, roles (add and remove them if you manage roles), mutual communities and a "Message @name" box.
  - Its ⋯ menu offers Copy ID, Block, and only the moderation you may use: Timeout (the first timeout UI), Kick, Ban and Server mute.
  - "View full profile" opens About and Mutual communities tabs.
  - The data comes from a new route with the same privacy rules as the lobby.
- **Fixes:**
  - **Mention Everyone:** `(@everyone)`, `hi,@everyone` and edits slipped past it, and `@here` is now covered too.
  - **Moderation:** a timed-out moderator could unban.
  - **Voice:**
    - deleting a voice channel left its voice room running;
    - while reconnecting, the footer said "Voice Ready" and activities showed the Join bar.
  - **Members page:** it offered actions on higher-ranked members, and showed "@EVERYONE" as the role.
  - **Community menu:** it lacked Audit log, Bots and Voice & media.
  - **Readability:** voice-roster icons were unlabeled for screen readers, and avatar initials were nearly unreadable in the light theme.
- **Tests:** new end-to-end specs for the permission matrix, voice drops and drops during activities.
- **Upgrade notes:** no migration and no configuration change.

## [Unreleased] - A Discord-like profile card - 2026-10-13

- **Click anyone, see their profile:** the member list, a chat author's avatar or name, the voice roster (left click; right-click and "⋮" keep the moderator menu), the DM header and DM authors, and your own account avatar all open the same card. One card at a time; another person switches it. docs/PROFILES.md.
- **The card:**
  - Banner, or a band in the avatar's colour. Avatar with the status the member chose, and "In voice" apart from it. Names as Discord shows them, the custom status, About me, Member since (account and community, in the viewer's language), roles, mutual communities, and the volume slider in voice.
  - **Message @name:** type and press Enter. The DM is opened or created, the message is sent, and the conversation opens in the centre column.
  - **"⋯" menu:** Copy user ID, Block or Unblock, and for moderators only what they may do: Timeout (60 s to 1 week) or Remove timeout, Kick, Ban with an optional reason, and Server mute in your voice room. Each action asks first, through the existing routes. Your own card has Edit profile and the status choice.
  - **Roles:** a role manager adds (+) and removes (×) roles through the existing role route. Refusals are shown inline, in the viewer's language.
- **Full profile:** a larger modal with About and Mutual communities tabs. Each community links to its lobby.
- **Image viewer:** `components/ImageLightbox.tsx`, a shared viewer for avatars and banners, with "Open original". It respects reduced motion.
- **API:** `GET /api/servers/{id}/members/{userId}/profile` returns one member's profile, projected for the viewer:
  - A 404 unless both people belong to the community.
  - It uses the lobby's own privacy projection. A hidden profile keeps only names and roles, and a block in either direction keeps the payload just as small.
  - A member who blocked you gets the same answer as a hidden profile. The card is cached per viewer and re-asked after 30 seconds, and roles are read fresh before each change, so a whole-set role update can't undo someone else's change.
  - `viewerCan` follows the moderation gate's rules, which now share their permission map and rank check with it (`lib/member-capabilities.ts`).
  - New queries in `@lobbyforge/db`: `getMemberProfileRecord` and `listMutualServers`.
- **Fixes:**
  - The member list's card showed the voice state instead of the status the member chose, so Idle never appeared. Do Not Disturb had no label at all.
  - The lobby smoke test counted an old placeholder ("No bio provided.") that could never appear. It now checks that About me says something.
  - Found by the first run of `e2e/profile-card.spec.ts`: a screen reader read the community menu's button as "E E2E Compose Stack expand_more", and each entry with its icon's name first ("history Audit log"). The button is now named "{community} — open the community menu", and the icons are hidden from assistive technology.
  - Found in its light-theme screenshots: someone without a photo is shown by their initial on a dark circle that ignored the theme. On the light theme the initial read 1.9:1 in the voice roster, chat, DMs, the member list and mentions. The circle (`secondary-container`) now follows the theme: unchanged on dark and dim, light grey-blue on light (12:1).
- **Not in this change:** clickable mentions (messages don't store mention ids yet), pronouns, handles and profile accent colours (they need new columns).
- **Tests:** route tests for every access, privacy, block and `viewerCan` rule; a test that checks `viewerCan` against the real moderation gate over every combination; db unit tests, plus an integration test that runs with `TEST_DATABASE_URL`; component tests for the card, the full profile, the viewer and every entry point; and `e2e/profile-card.spec.ts`. It passes twice in a row on a freshly rebuilt compose stack, with registered members who have photos, a banner and bios, and covers every entry point, the keyboard, the image viewer, a DM that arrives on the other side, both tabs, Timeout and its lift, role add and remove with the member list regrouping, a hidden profile, your own card, the community menu and the roster's mute icon.

## [Unreleased] - The A-to-Z e2e run: fixes and three new specs - 2026-10-13

A run through the whole product against the compose stack, with several people at once. It added three specs and fixed what they found.

- **New specs** (compose stack only, `LF_E2E_BASE_URL`):
  - `e2e/roles-permissions-matrix.spec.ts`: every permission, allowed and refused. Channel visibility, send, history and the live chat subscription, Manage Messages, Mention Everyone, polls, channels, roles and their escalation rules, the kick, ban and timeout hierarchy with `actor_timed_out`, voice grants, server mute and disconnect, Manage Community, the audit log, bots' hidden access, administrator, a channel's last gate role, and sanctions across leave and rejoin. It also checks the UI entry points.
  - `e2e/voice-drop.spec.ts`: what others see of your voice controls, a 10-second blink, a drop past LiveKit's two minutes (others see it, the way back, the online and tab-visible retries, the 10-minute cap), reloads with and without the microphone permission, the endings that must not come back, and a duplicated tab.
  - `e2e/activity-voice-drop.spec.ts`: Dice, Poll, Quiz, Hushle, Vampire Village and Watch Party with a player offline or reloading, the same-`actionId` retry and a lost answer, presence changes, host transfer, stale presses, hidden information in responses and WebSocket frames, `voice_required`, timeouts and one activity per channel.
- **Voice:**
  - While LiveKit resumed its signal connection, the footer said "Voice Ready" and Disconnect was disabled, as if the person had left. It now says "Reconnecting..." and Disconnect works.
  - While LiveKit reconnects, the full-screen voice view stays open, and the activity view no longer offers "Join the voice channel to play".
  - Deleting a voice channel left its LiveKit room running, and the people in it kept talking in a room nobody could see. The delete now ends the room, and they see "The voice channel was closed."
  - At 390 px the voice drop strip squeezed its sentence to one word per line. The button now wraps onto a line of its own.
  - The voice roster's status icons have names. A screen reader read the mute icon as "mic_off". Muted, server muted, camera on, sharing screen and, on your own row, Deafened are now named images, in English and Turkish.
- **Moderation and roles:**
  - A timed-out moderator could still lift a ban. The unban route now refuses them with `actor_timed_out`, like every other moderation action.
  - The Members page offered Kick, Ban and the role picker on members ranked at or above the viewer, and every role in the picker. Each action now needs the viewer to outrank the member, as the routes do, and roles at or above the viewer's highest aren't offered.
  - The Members page showed "@EVERYONE" as a moderator's role. A member's display role is now the highest role other than `@everyone` everywhere: the Members page, the lobby member list and the members API. Rows saved the old way show right too. The admin Roles page counts each holder of a role once (a member's display role was counted twice), and the hub's Roles tab counts every holder.
  - Kick, ban, role and timeout refusals all carry a `code`. The Members page used to show the server's English error to every language. It now says each refusal in the viewer's language, like the profile card.
  - The community menu has entries for Voice & media, Bots and the Audit log, each shown when its page opens for you. A moderator with only View Audit Log reached the audit log by URL alone.
  - A role change made in a profile card now moves the member to the right group in the lobby member list at once, instead of on the next page load.
- **Mention Everyone:**
  - `(@everyone)` and `hi,@everyone` went out without the permission, and so did an edit that added `@everyone`. Posts, edits and chat polls now share one rule.
  - `@here` needs Mention Everyone too, as in Discord. The role editor calls it "Mention @everyone and @here".
- **Tests:** unit, route and component tests for every fix. The specs are updated for the new menu entries, `@here`, the code on the owner refusal and the named mute icon, and all three pass on a freshly rebuilt compose stack.

## [0.2.0-rc.10] - 2026-10-13

rc.9 plus the fixes from its update drill. Update from rc.8 or rc.9 with `lfctl update apply`.

**Upgrade notes:**
- These are the same as rc.9's, and the session secret note matters most. An install whose `LOBBYFORGE_SESSION_SECRET` is a published placeholder no longer starts. The update now stops before it changes anything, and says why.
- Nothing new needs migrating.
- Optional, for an install updated from rc.8: check out the tag, then re-render the configs with `scripts/render-configs.sh` and recreate nginx. This adds rc.9's `/api/plugin-ui/` location.

The rc.8 → rc.9 update drill on a local production stack passed. Apply, rollback, roll-forward, restore and forced failure all work, and an rc.8 session stays signed in and slides to 7 days. These are the fixes for what it found (docs/BETA_RELEASE.md, "Update drill — rc.8 → rc.9").

- **A placeholder session secret stops the update before anything changes.** On rc.8 with the `.env.example` secret, `update apply` used to back up, pull, **migrate** and recreate the services. It then failed with only "container … is unhealthy". It recovered the old containers, but never said why.
  - `lfctl update apply` now checks `.env.prod` first. A published placeholder, or a secret under 32 characters, is refused with the reason and "Nothing was changed.": no backup, no docker call.
  - The migrator (`packages/db`, `dist/migrate.js`) refuses the same secrets before it connects, unless `LOBBYFORGE_DEV_STACK=true`. Even the previous release's `lfctl`, which runs the new image's migrator first, now stops before any schema change and prints the reason. A migrator run without a secret (dev, CI) is unaffected.
  - When the recreate fails, `lfctl` prints the new containers' last log lines before it restores the old ones.
  - lfctl's copy of the placeholder list is tested against `@lobbyforge/core`'s.
- **web exits on a refused secret.** In production, `next start` 16.4 only logged an error thrown from `instrumentation.register` ("Failed to prepare server"). It then answered 500 on every route, `/api/health` included, until the healthcheck gave up. web now exits with code 1.
- **Doctor's TURN check reads the real configuration:** `LOBBYFORGE_TURN_SECRET`, plus `LOBBYFORGE_TURN_HOST` or the base URL's host, the same test the voice token uses. It read `LOBBYFORGE_TURN_URL` and `LOBBYFORGE_TURN_STATIC_AUTH_SECRET`, which nothing sets, so every install was reported as "not configured". `infra/docker/.env.example` documents the right variables.
- **Hushle started through the API on a fresh instance:** the start now seeds the built-in card packs on first use. Before, the start answered 404 until something had listed the packs.
- **k6 load script:** each presence user's first heartbeat sent the session cookie twice. The server refused those requests with 400 "Duplicate session cookie", so every run failed its own 5% error threshold. The cookie now goes through k6's cookie jar.
- **Release image label:** `org.opencontainers.image.version` is the release version (`0.2.0-rc.10`), no longer `candidate-<sha>`.
- **Docs:** UPDATES.md says what `update apply` really does, now that the "locked" note is gone. It also says what `update apply` doesn't change: compose, nginx and coturn templates come from the checkout. Re-render after checking out a release whose notes say so, for example for rc.9's `/api/plugin-ui/` nginx location. BETA_RELEASE.md has the drill.
- **Tests:**
  - CI's typecheck now covers the Playwright specs (`apps/web/tsconfig.e2e.json`). It caught two errors that nothing had checked before.
  - A test that starts Node to load the operator script gets 60 s instead of 5 s; it timed out on a busy Windows runner.

## [0.2.0-rc.9] - 2026-10-13

The release candidate after rc.8. It collects everything below, down to and including "Dependency cleanup, fourth batch".

- **Security:** a second full security review (docs/SECURITY_REVIEW_2026-10-13.md) found no critical or high issue. All 7 medium and 11 low findings are fixed:
  - bot token rotation and event endpoints;
  - a fair event-delivery queue;
  - a gzip bomb at plugin review and a cheaper plugin asset route;
  - guest image uploads and a daily upload budget;
  - client message metadata;
  - blocks in live chat;
  - audit rows shown per viewer;
  - DMs only between people who share a community;
  - timed-out members can neither play nor moderate;
  - desktop navigation pinned to its instance;
  - checked GIF frames.
- **Sessions no longer end after an hour:** the session now slides. It stays valid for 7 days without a request and is re-signed while in use. The 30-day absolute lifetime and server-side revocation are unchanged.
- **Marketplace plugins:**
  - Plugin frames can play sound and video, run WebAssembly and fetch their own files.
  - A plugin can ask for outside sites, its own site as the UI, timers, per-community storage and app chat lines. It gets them only once the owner approves them at install.
  - Plugin names and summaries can be translated.
- **Activities:**
  - After a dropped connection, activities re-read their state, and taps retry with the same `actionId`.
  - A late press no longer lands on the next card, question or poll.
  - After a reload or a drop, voice comes back with one click, or automatically with the microphone off.
- **Chat polls** in text channels, for roles with Create polls.
- **Languages:**
  - A user's language is saved on their account.
  - The instance default is set in the admin panel and at setup.
  - Bots speak each viewer's language.
  - The desktop app and its installer use every shipped language.
- **Dependencies:**
  - Next.js 16.4 (`middleware.ts` is now `proxy.ts`), ioredis 6 with a fast-fail reconnect, TypeScript 6;
  - nodemailer 10.0.14, livekit-server-sdk 2.19.1, LiveKit server 1.13.9, ws 8.22, altcha 3.3 with altcha-lib 2.6.
- **Upgrade notes:**
  - **An install whose `LOBBYFORGE_SESSION_SECRET` is a published placeholder no longer starts.** Before updating, set a real secret (`openssl rand -hex 32`) in `.env.prod`. Changing it signs everyone out. `install.sh` has always generated a random one.
  - Migrations 0047 (chat polls), 0048 (account language) and 0049 (activity timers) are expand-only. Rolling back to rc.8 is safe.
  - Guests can no longer upload avatars or banners.
  - A marketplace plugin that asks for more than the sandbox needs the owner's approval through the marketplace dialog.

## [Unreleased] - Security review 2026-10-13 and fresher docs - 2026-10-13

- **Report:** docs/SECURITY_REVIEW_2026-10-13.md (Turkish): six areas reviewed on main 7798270; no critical or high finding; 7 medium and 11 low, every one fixed (#106, #108, #112), plus the one-hour session bug. The upgrade note: an install running on a published placeholder session secret no longer starts.
- **Docs that disagreed with the code:** the README feature table and layout (all six activities ship, chat polls, Bot API v2, the sandboxed marketplace with owner approval, bot protection, email, languages; the plugin worker and the example plugin; no more `packages/i18n`), EXTENDING.md's bot sections (events, slash commands and webhooks exist since Bot API v2; role-gated channels can be granted; what is still missing) and its marketplace path, and ADR-007's status (implemented).

## [Unreleased] - Sessions no longer end after an hour - 2026-10-13

- **The bug:** the session cookie lived one hour and nothing re-signed a cookie that was still valid — `POST /api/auth/guest` only ran once the cookie was already gone. A signed-in user was signed out an hour after signing in, however active they were, and a guest lost their identity. Found by the 2026-10-13 security review.
- **The fix — a sliding session:** the cookie now stays valid for 7 days without a request, and every API request (after the revocation check) and every page load re-signs a valid cookie once less than half of that is left, keeping the same session id and `auth_time`. Refused answers and the sign-in, guest and logout routes never get one, so signing out still signs out. The 30-day absolute lifetime (`LOBBYFORGE_SESSION_MAX_AGE_DAYS`) and server-side revocation (sign out everywhere, password change, operator reset) are unchanged and still end a session.
- **For developers:** `guestSessionNeedsRefresh` (`@lobbyforge/core`), `apps/web/lib/session-refresh.ts`; `GUEST_SESSION_TTL_SECONDS` is now 7 days. docs/GUEST_AUTH.md "Lifetime".
- **Also from the review, small hardening:**
  - In production the Google sign-in state cookie is `__Host-lf_oauth_state`, so a sibling subdomain cannot plant a state and finish a sign-in with the attacker's Google code (login CSRF).
  - Editing a profile refuses control characters (CR/LF, U+0085 …) in the display name, as sign-up already did.
  - Hushle's `set-explainer` carries `turnNumber` like the other turn moves: a late pick cannot change the next turn's explainer.
  - The automatic voice retry on `online` / tab-visible (after LiveKit gave up) only runs within 10 minutes of the last time in voice; later, the one-click "Back to #channel" strip is offered instead. An automatic return still joins with the microphone off, on purpose.

## [Unreleased] - Security fixes: marketplace and plugin sandbox - 2026-10-13

From a security review of the marketplace, plugin install and plugin sandbox.

- **A gzip bomb at review (PLUG-001, medium):** approving a plugin downloads its bundle and scans it. The scan inflated the whole archive before checking its size, and the download allowed 16 MiB, so a small gzip could make the server allocate gigabytes. The review download now stops at the installer's 10 MB bundle cap, the scan refuses anything bigger before decompressing, and decompression itself stops at the archive cap (50 MB of data plus headers): such a bundle is refused as a "possible tar bomb" without being inflated.
- **A cheaper public asset route (PLUG-002, medium):** `/api/plugin-ui/…` validated the whole bundle (the manifest and up to 32 locale files) and read the whole requested file, up to 16 MiB, on every request, HEAD and Range included, with no limit. The validated manifest is now cached per plugin, version and digest (bounded, emptied by every install); only the small active record is read per request, so a withdrawn approval still applies at once. Files are streamed from their descriptor: HEAD reads no body and a Range reads only its bytes. A per-address token bucket in memory (no database or Redis; a burst of 1000 requests refilled at 50 per second) answers 429 with `Retry-After`. Every header and refusal stays as it was.
- **Stricter tar parsing (PLUG-003, low):** the header scan dropped every space and NUL from a size field, read garbage as 0 and never checked the header checksum, so it could disagree with GNU tar about where entries begin. It now reads numeric fields strictly (leading spaces, octal digits, then only NUL or space), verifies every header's checksum, refuses GNU base-256 sizes and entries that run past the end of the archive. The walk after extraction has no depth cap any more and no longer ignores errors.
- **What the frame's CSP promises (PLUG-004, docs):** "no network" for a plugin's own UI was more than CSP can enforce. The policy stops fetch, XHR, WebSocket, image and media requests beyond the plugin's own files and approved origins; it does not cover WebRTC, and a frame can navigate itself to any origin the lobby page may frame. The frame's policy now adds `webrtc 'block'` (enforced where browsers implement it, ignored elsewhere), and the docs, code comments and the approval dialog (English and Turkish) say what is actually guaranteed.
- **Reserved official ids:** a marketplace plugin can no longer use an official plugin's id (`hushle`, `quiz`, `poll`, `dice-bot`, `watch-party`, `vampire-village`, in any case). Submit and install refuse it with 409 `reserved_plugin_id`; the installer, the dynamic registry, the pages' `frame-src` and the asset route ignore it. Before, a bundle named like one could be listed, installed and loaded by the worker, have its UI files served under the official id, and add its approved site to every page's `frame-src` — though the compiled-in plugin still won every lookup and its translated names were already protected.
- **Approving what was reviewed:** the review route takes the `version` the reviewer saw and answers 409 `version_changed` (with the current version) when the publisher resubmitted in between. An approval is also written only while the catalogue row still has the version and bundle URL that were downloaded and pinned, in the same statement, so a resubmission during the download cannot be approved unseen. The moderation screen sends the version and says when this happened (English and Turkish).
- **Bounded reads:** `manifest.json` is read with the 64 KiB cap, and the bundle digest (web and plugin-worker) checks each file's size against what is left of the 64 MB cap before reading it.
- **Plugin files stay out of CDNs:** every response of the asset route carries `CDN-Cache-Control: no-store` and `Cloudflare-CDN-Cache-Control: no-store`. Browsers keep caching the files for a year; Cloudflare, which caches `.js` by extension and ignores `Vary`, no longer could hand a cached plugin script to a request the route refuses (docs/DEPLOY_CLOUDFLARE.md).
- **nginx:** `/api/plugin-ui/` has its own location, without the server-wide `X-Frame-Options: DENY` (wrong for a framed page) and `Referrer-Policy` (it overrode the route's `no-referrer`).
- **Tests:** a gzip bomb and an oversized download at review and in the scan; malformed size fields, bad and signed checksums, base-256 sizes, truncated archives, a link 25 folders deep and a walk error; streaming, HEAD, Range, cancellation, the manifest cache and its bound, the rate limit; official ids at submit, install, the installer, the registry, the frame-src reader and the asset route; the review version check and the guarded update; the manifest and digest caps (web and worker); the CDN headers, `webrtc 'block'` in the browser suite, and the nginx location.
- **Docs:** ADR-007, PLUGIN_SDK.md, PLUGIN_PUBLISHING.md, EXTENDING.md, DEPLOY_CLOUDFLARE.md and ACTIVITY_INTEGRITY_AND_RESILIENCE.md.
## [Unreleased] - Security fixes: activities and host effects - 2026-10-13

From a read-only review of `main` 7798270 (activities, realtime, voice, plugin host effects).

- **ACT-001, the unused SSE route is gone:** `GET /api/servers/{id}/activities/{sessionId}/stream` only read the session cookie. It sat outside `withApiSecurity`, and the first revocation check came 30 s after it opened (and failed open). No client has used it since the room page moved to the WebSocket gateway (M20-bis), so the route and its tests were deleted rather than fixed. This also closes ACTIVITY_INTEGRITY B-10 and PLUG-V-03. REALTIME.md, PLUGIN_SDK.md, EXTENDING.md and the other pages that named it are updated.
- **ACT-002, a due timer always runs before an action:** the actions route built the timers' system context only when the row read *before* the session lock had timers. A timer that another call set in between, and that was already due, could be overtaken by the action. The context is now built when the plugin may set timers (`timers` permission) or the row has some. Under the lock, a due timer with no context yet gets one, read through the lock's own transaction.
- **Timed-out members don't play:** starting an activity, and every action except those marked `allowOutsideVoice` (leaving), is refused for a member who is timed out in the community, with `403 timed_out` and `until`. This already applied to chat, poll votes, slash commands and invites. Host actions are refused too. The lobby shows the refusal in English and Turkish (`room.activity.error.timedOut`).
- **Uninstalling or disabling an app stops it:** `DELETE /api/servers/{id}/apps`, and saving an app as disabled, now end its open sessions in the community. Each ending is published and audited as `activity.end` with `reason: "app_uninstalled"` or `"app_disabled"`, and nothing of the plugin runs on the way out. A session left open by a race refuses actions with `403 app_not_installed` (translated). Its due timers are dropped without running on GET, action and end, and storage writes and chat lines are dropped. The start route's own refusal now carries `app_not_installed` too.
- **App chat, per-community limit:** besides one per 5 s and 30 per hour per session, an app may post at most 60 lines per hour per community and plugin. Restarting the activity no longer resets the budget.
- **App chat, private channels:** pointing an app's messages at a role-gated channel ("App messages go to") takes what granting a bot that channel takes: Manage Channels (owners and administrators have it) and seeing the channel. Manage Server alone gets `403 chat_channel_not_allowed`. Saving other settings keeps a channel someone else chose, and the channel is still checked again at post time.
- **App chat goes through the Moderation Bot:** a `chat.post` line goes through the community's Moderation Bot blocked-word and link rules, like member messages and webhook posts. Mentions in app lines are already inert, so the mention rule does not apply. A line the rules block is dropped and logged, and the action still applies. The block is audited as `bot.moderation.block` with `targetType: "plugin"` and `kind: "app"`, and without the line's words when the channel is role-gated. There is no notice and no exemption. The rate limits run first, so blocked lines cannot flood the audit log.
- **Timer catch-up is bounded:** an idle 1 s timer that re-arms itself used to make every touch replay up to 32 missed firings under the session lock. For a sandboxed plugin that meant 32 worker calls. Now, when a timer re-arms itself under the same id at a time that is already due, it moves to the latest due occurrence of its period: the backlog collapses to one more firing. A touch also runs at most 8 timer calls and starts none after 500 ms, and the next touch continues. An action that finds timers still due after that answers a retryable `409 timers_pending`, which the lobby retries by itself, instead of running ahead of them. The harness's `advanceTime` still fires every occurrence. The rule is documented in PLUGIN_SDK.md "Timers fire lazily".
- **Panels stop re-reading in a loop:** with two or more due times that stayed pending, `pickNextCheck` alternated between them, and every open panel re-read the session every 1.5 s forever. Due times already re-read for now back off together: 2 s, 4 s, 8 s and so on, up to once a minute. A new due time is still read at once. `nextActivityCheck` takes a re-read history (`recordCheck`) instead of one "fired for" time.
- **For developers:** new `lib/activity-app-install.ts` (`isAppEnabled`, `endAppSessions`) and `endOpenGameSessionsForPlugin` in `@lobbyforge/db`. `runDueTimers` reports `calls`, `skipped` and `pendingDue`, and `MAX_TIMER_FIRINGS_PER_TOUCH` is now 8 (it was 32). `moderateAppMessage` lives in `lib/bots/moderation.ts`. New refusal codes: `timed_out`, `app_not_installed`, `timers_pending`, `chat_channel_not_allowed`.
- **Tests:** route tests for the timer that fell due between the read and the lock (with and without the `timers` permission), timeouts (refused, leaving allowed, start refused), removed apps (actions refused, due timers dropped on GET and end, effects dropped mid-call, sessions ended and audited on uninstall and disable), catch-up (collapse, the 8-call bound, the retryable 409 then the action in due order) and the private chat channel rule. Unit tests cover the collapse, the wall-time budget, the per-plugin chat limit, moderation of app lines, the re-read back-off (and a hook test proving it), and the new refusal sentences. A db integration test covers `endOpenGameSessionsForPlugin`.
- **Upgrade notes:** no migration and no new dependency. A community's role-gated "App messages go to" channel that was set before stays in place.
## [Unreleased] - Security fixes: bots and webhooks - 2026-10-13

- **A bot's private channels are not a way in (BOT-001):** a manager who cannot see a role-gated channel the bot was granted (and cannot grant private channels) can no longer issue or rotate that bot's token, nor add `read_messages`, `send_messages`, `receive_events` or `slash_commands` to it: `403 cannot_take_hidden_access`, explained in the admin's language (English and Turkish). The owner, administrators and Manage Channels holders are unaffected; revoking a token or removing a permission is always allowed. The same rule as the channel-access routes' `cannot_change_hidden_access`.
- **A token change ends the old holder's endpoint (BOT-002):** rotating or revoking a token now also switches the bot's outgoing event endpoint off (`disabled_reason = 'token_changed'`, audited as `bot.event_endpoint.disable`), in the same transaction. Nothing more is delivered to that URL, a queued retry is dropped, a synchronous interaction answer that arrives after the change is not posted, and a command of the bot answers `bot_offline` until the bot is reachable again. A manager cannot switch such an endpoint back on (`409 endpoint_token_changed`); the bot saves it again with its new token, which issues a new secret. An endpoint save is bound to the token that authenticated it, so one that started with the old token (say behind a slow DNS answer) cannot land after the rotation (`401`). Admin → Bots gains **Remove endpoint** (with a confirmation) and says why an endpoint stopped; the rotate and revoke dialogs say the endpoint goes off too.
- **Fair event deliveries (BOT-003):** the in-process delivery queue keeps a lane per bot, grouped per server, and hands out free slots round-robin (next server, then its next bot; interactions first within a lane). At most 16 deliveries in flight per process, 8 per server and 2 per bot; 100 waiting per bot (a full lane drops its own oldest delivery) and 2 000 in all (the longest lane gives one up). An endpoint that answers slowly (over 1 s, even with a 2xx) or fails three times in a row is backed off (1 s doubling to 60 s) until it answers quickly again. Before, 16 global slots and a global queue let one slow-but-successful endpoint hold up every other community.
- **No network details in refusals (BOT-004):** saving an endpoint whose host does not resolve to public addresses answers one fixed sentence; the address, host name and resolver error go to the server log only. Single-label hosts (`https://postgres/`, Docker service names) are refused.
- **Re-authorization on every attempt:** every delivery attempt, each retry and the synchronous interaction answer re-read the bot and its endpoint (bot enabled, `receive_events`, endpoint on, subscribed and the very URL and secret the delivery was signed for, the channel still reachable). Before, retries only checked the endpoint and the answer used the bot as it was before the request.
- **DNS cannot pin the threadpool:** endpoint hosts are resolved with the c-ares resolver and a 2 s deadline (save and every attempt), off libuv's threadpool, which scrypt shares. The image sets `UV_THREADPOOL_SIZE=16` for the web process (infra/docker/README.md).
- **Welcome Bot channel (bug):** choosing the Welcome Bot's channel checked access with a made-up bot id (`'welcome'`), which Postgres refuses for the uuid column (`22P02`), so the save failed with a 500. The route now checks the existing bot's own access, or the open-channel rule before the bot exists (`getChannelOpenToBots`, no bot id).
- **Documented, unchanged:** a bot token outlives the member who issued it, and `member_leave.reason` (leave, kick, ban) reaches every bot with `read_members` (docs/BOTS.md).
- **Tests:** token and PATCH refusals (BOT-001); rotate and revoke switching the endpoint off, with deliveries, retries, synchronous answers and `bot_offline` checked together (BOT-002); per-bot caps, drops, round-robin, the flooding server and back-off (BOT-003); no IP or host in a refusal (BOT-004); re-authorization of retries and answers; the c-ares deadline; the Welcome Bot channel against the real queries on a Postgres stand-in that refuses a non-uuid id; the query-level token, save and re-enable rules (and their Postgres integration tests).
- **Docs:** BOTS.md (tokens, private channels, audit, `member_leave`), BOT_API_V2.md (§1.1, §3.2, §5.2, §7, §8) and infra/docker/README.md.
## [Unreleased] - Security fixes: authorization, messages and privacy - 2026-10-13

From a read-only security review of main `7798270` (authorization, communities, messaging).

- **No client metadata on messages (AUTHZ-008):** `POST /api/servers/{id}/channels/{channelId}/messages` refused only some top-level key names, so a member could attach about 1 MB to every message (30 a minute) that each lobby render, list response and live subscriber then carried. No first-party client sends metadata: it is now refused outright (`400`, `code: "metadata_not_allowed"`), and the body is strict (an unknown field is a `400`). Message create and edit routes, channel and DM, cap the body at 16 KiB (`413`); 4000 characters of any script fit. The DM send cap moves from 8 KiB to the same 16 KiB, so a 4000-character message in a 3-byte script is no longer refused.
- **Blocks in live chat (AUTHZ-009):** the server render and the REST list masked a blocked author's messages, but the live chat showed them as they arrived, poll included, and raised desktop notifications for them. The live feed and the local echo now read the viewer's block list: the same placeholder (no author, no words, `poll: null`) and no notification. Someone blocked while the channel is open is masked at once.
- **Audit metadata per viewer (AUTHZ-010):** View Audit Log holders read invite codes, private channels' names, topics and role lists, and the Moderation Bot's matched word or link host for channels they cannot see. New `invite.create` / `invite.redeem` rows log the invite id instead of the code. `GET /api/servers/{id}/audit-logs` and Community Settings → Audit Log project every row on read, old rows included: `code` only for MANAGE_SERVER, `name` / `topic` / `visibleToRoleIds` and `detail` / `excerpt` only for channels the viewer may see.
- **Role delete names visible channels only:** the 409 for a role that is some channels' last gate named every such channel, including private ones the actor cannot see. It now names only the visible ones and counts the rest (`hiddenChannelCount`); the role editor says both, in English and Turkish.
- **`@everyone` stays the lowest role:** a MANAGE_ROLES member could move `@everyone` to any position below their own, although the route said they could not. Moving `@everyone`, or moving another role to or under it (which pushed it up), is now refused for everyone, the owner included (`400`, `code: "everyone_role_lowest"`).
- **Live chat needs message history:** subscribing to a chat topic on the gateway did not require `read_message_history`, while reading the same channel over REST did. It now does (`administrator` implies it; `manage_channels` does not), so a write-only role no longer keeps the live feed.
- **DMs need a shared community:** `POST /api/dm` opened a conversation with any user id, and an unknown id answered `500` (the foreign key), which told real ids from made-up ones. The recipient must now share a community with the caller (both current members, neither banned) or already have a conversation with them; everything else is the same `404`. Blocks keep their `403`.
- **Typing respects blocks and privacy:** the typing indicator listed users the viewer blocked, users who blocked the viewer, and users whose online status is hidden from the viewer. They are now left out, like in presence.
- **Timed-out members cannot moderate:** a timed-out moderator could still kick, ban, time out, change roles, voice-mute and disconnect. The shared moderation gate now refuses an actor who is timed out (`403`, `code: "actor_timed_out"`, with `until`); the owner is never refused. The voice roster says why, in English and Turkish.
- **Tests:** message body caps and metadata refusal (channel and DM); the live chat block placeholder, poll and notification (component test); audit projection in the route and the audit page loader; invite audit rows without codes; role delete 409 and `@everyone` moves; gateway chat subscribe and the REST list without history; DM eligibility (route and query); typing filters and the id-keyed typing reader; the timed-out actor across timeout and ban routes.
- **Docs:** MESSAGES.md (body cap, no client metadata, blocked authors, direct messages), ROLES.md, INVITES.md, REALTIME.md, PRESENCE.md (typing), WEB_APP.md (audit projection), BOTS.md and BOT_API_V2.md.
## [Unreleased] - Dependencies: Next 16.4, ioredis 6, TypeScript 6; `middleware.ts` is now `proxy.ts` - 2026-10-13

- **Next.js 16.4.0** (was 16.3.8). Its strict route matching found a real bug: the `@modal` interceptors for `/admin/…` and `/settings/…` caught URLs that had no page of their own, so a click from the lobby worked while a hard load of the same URL 404'd (`/admin` itself among them). New canonical routes: `/admin` redirects to the admin overview (`/admin/settings`), and an unknown `/admin/…` or `/settings/…` is a proper 404. The modal map opens `/admin` on the overview too.
- **`middleware.ts` → `proxy.ts`:** Next 16 renamed the file convention. The function is `proxy`, and the `runtime: 'nodejs'` option is gone (a proxy always runs in Node.js; setting it is now a build error). Behaviour is unchanged: the same CSP, nonce, origin rules and CAPTCHA/plugin frame sources.
- **ioredis 6.0.0** (web app and ws-gateway; was 5.x). Version 6 grows the reconnect delay exponentially to 5 s, so a command against a Redis that is down waited about 70 s before failing; every client now sets ioredis 5's linear delay (`min(times * 50, 2000)`, about 10 s in all), so outages still fail fast and fail-closed paths answer.
- **TypeScript 6.0.3** (was 5.9). TypeScript 6 no longer includes every `@types` package automatically: the shared `tsconfig.base.json` now lists `types: ["node"]`.
- **Also:** altcha 3.3.0 with altcha-lib 2.6.0 (moved together), @playwright/test 1.63.0, LiveKit server v1.13.9 (compose files and the Trivy scan pin), actions/setup-node 7.1.0, upload-artifact 7.0.2, download-artifact 8.0.2.
- **Audit:** `pnpm audit` reports one advisory, `braces` (GHSA-vfj7-8cjw-p6xm), reachable only through `@next/eslint-plugin-next` at lint time; it has no patched release. It is not in any production image.
## [Unreleased] - Security fixes: uploads, infrastructure and desktop - 2026-10-13

- **Guests cannot upload images (FILE-001):** `POST /api/users/me/avatar` and a new banner on `POST /api/users/me/banner` answer `403` with `code: "guest_upload_forbidden"` for a guest account. Guests cost nothing to create, and each could keep ~14 MiB of avatar + banner under the per-user quota. Profile settings hide the banner upload, disable "Change avatar" and say why, in English and Turkish. Removing a banner stored earlier stays allowed.
- **Daily upload budget per address (FILE-001):** every accepted avatar, profile banner and server banner counts against a 64 MiB / 24 h budget for the client address (Redis in production, `lf:<env>:rate-limit:upload-bytes:<hash>`). Past it the route answers `429` with `code: "upload_budget_exceeded"`; the settings page explains it in both languages. Without a trusted proxy (every visitor is "unknown") one 512 MiB instance-wide budget applies instead. A Redis outage in production refuses uploads (fail closed), like the other limits. A refused upload uses none of the budget.
- **Doctor `image_storage` (FILE-001):** warns when the images kept in Postgres (avatars, banners, logo) reach 1 GiB, or `LOBBYFORGE_IMAGE_STORAGE_WARN_MB`, with a breakdown by accounts, guests, server banners and logo. It is measured with `octet_length`, so no image is loaded.
- **GIF frames are checked (FILE-V-01):** the image validator walks every GIF block (extensions, image descriptors, colour tables, data sub-blocks). Every frame must lie inside the logical screen, which keeps the size limits. A truncated or malformed GIF is refused: no frame, no trailer, an unknown block or a bad LZW code size. Stored GIFs pass the same walk before `/api/users/{id}/avatar|banner` serves them, so a decoder bomb saved earlier is no longer served.
- **Dev stack on loopback, placeholder secrets refused (INFRA-V-02):** every port the dev compose and the e2e overrides publish binds `127.0.0.1`, LiveKit's UDP media included. LiveKit advertises 127.0.0.1, so dev voice still works on this machine. The optional coturn listens on loopback too. web and ws-gateway now refuse to start (and the session-secret readers refuse to work) with a missing or short session secret, or with a published placeholder: the `.env.example` value, the dev compose default, `CHANGE_ME…` and similar. The only exception is `LOBBYFORGE_DEV_STACK=true`, which only the dev compose sets (`@lobbyforge/core` `requireSessionSecret`). The README quick start now says what the dev stack really runs (the whole app, production build, local only), and how to run from source.
- **Doctor `client_address` (INFRA-V-01):** with a trusted proxy set, it warns when this request, or at least half of the last 256 proxied requests, comes from a gateway address. That is the container's default gateway or a private `x.x.x.1`: Docker's userland proxy hands every IPv6 visitor to nginx as the bridge gateway, so they share one rate-limit bucket. DEPLOY_CLOUDFLARE.md ("Client addresses behind Docker") gives three fixes: IPv6 on the edge network, `userland-proxy: false`, or no `AAAA` record. `install.sh` is unchanged.
- **Desktop navigation pinned (DESK-V-01):** the window only shows the connected instance's exact origin and the bundled connect screen. Other `http(s)` pages open in the system browser (at most one a second). `file:`, `data:` and custom schemes are dropped. `window.open` never creates a second app window. The main window is built in `setup` (`"create": false`) with navigation, new-window and page-load guards. On macOS and Linux, where the navigation handler also sees iframes, a top-level page outside the instance is caught on page load. Sign-in pages of other sites ("Continue with Google") now open in the system browser.
- **Desktop handoff to its instance only (DESK-V-02):** the browser-login deep link is posted to the instance origin the login was started for, never `'*'`. Connecting, disconnecting and the launch restore all drop a pending login. The launch restore also re-validates the saved instance URL.
- **Hygiene:** `.claude/` is in `.dockerignore`; `.claude/settings.local.json` is no longer tracked (and is ignored).
- **Tests:** guest `403` and budget `429` on the avatar and banner routes; the budget module (memory, Redis, fail closed, unknown-address backstop, 24 h window); the profile page's guest state and translated errors; crafted GIFs (valid animated, oversized first frame, offset frame, truncated, malformed, served); the session secret in core, web (`getSessionSecret`, `instrumentation.register`) and the gateway (startup exit, handshake); loopback ports in every dev/e2e compose file; both Doctor checks and their wiring; the instance image total query; the Rust navigation, new-window, throttle and handoff decisions.
- **Upgrade notes:** a deployment whose `LOBBYFORGE_SESSION_SECRET` is a published placeholder no longer starts; set a real one (`openssl rand -hex 32`) in `.env.prod`. Changing it signs everyone out. `install.sh` has always generated a random secret. Guest images stored earlier stay until those guest accounts are removed; Doctor's `image_storage` shows their share. Local stacks: open `http://localhost:19520` as before. Other devices on the LAN can no longer reach the dev stack.

## [Unreleased] - Plugins: timers, storage and chat lines (host effects) - 2026-10-13

- **Effects:** a plugin's reducer stays pure and synchronous. To have the host do something more, it returns `withEffects(state, effects)` (`{ __lf: 1, state, effects }`; a marketplace `server.js` writes the object itself). The host checks every effect and the plugin's permission for it, and applies all of them or none: one invalid or unpermitted effect refuses the whole action with `422` and `code: "effect_refused"`. Returning the same state with no effects still means "no change".
- **Permissions:** `timers`, `storage` and `chat`. A marketplace plugin gets only what its manifest asks for and the instance owner approved at install; approving `permissions` now has an effect. A compiled-in plugin declares them in `manifest.permissions` (no dialog). `voice.mute` can be asked for, but its effect comes later (phase 3b).
- **Timers:** `timer.set { id, afterMs | at, action }` and `timer.clear { id }`. At most 8 per session, 1 s to 24 h, and the same id replaces. When a timer is due the host runs `action`, which must be declared `"role": "system"`. No client can send a system action (`403`, `code: "system_action"`), not even the host. Timers are stored with the state, in the same write (`game_sessions.timers`, migration 0049). They fire lazily, like the host hand-over: a state read, an action or an end request fires the due ones under the session lock, earliest first, each with `ctx.now` set to its due time. Two readers at once fire a timer once. The session GET returns the due times (never the actions) and the server's clock, and an open panel re-reads once at the earliest. Ending an activity drops the rest.
- **Storage:** `ctx.storage` is a read-only snapshot of the plugin's storage in the community (`get`, `keys`). `storage.set { key, value }` and `storage.delete { key }` write it after the action commits, as one batch. The limit is 64 KiB per community and plugin, checked before the commit and again when writing. A batch that no longer fits, or fails, is dropped and logged.
- **Chat lines:** `chat.post { text }` posts an app message, with no user or bot as author, the plugin named in `metadata.app`, and an **APP** badge (**UYGULAMA** in Turkish) in the lobby. Voice channels have no chat, so it goes to the text channel the community picks in Server settings → Apps → "App messages go to". With none picked, it is dropped. At most 500 characters. Mentions ping nobody. At most one per 5 s and 30 per hour per session; over that the line is dropped and the action still applies.
- **Example:** the sandbox buzzer (0.2.0, permissions `timers` and `storage`) reveals a round by itself 30 s after it opens, and keeps the community's five fastest reactions as a leaderboard. `pack.mjs` checks permission names and prints what the owner will be asked to approve.
- **For plugin authors:** `GamePlugin` takes a fourth type parameter, `ReducerResult<State>`, for plugins that return effects; existing plugins are unchanged. `createTestHarness` applies effects like the host and fires timers with `advanceTime(ms)`; it also has `getTimers()`, `getStorage()` and `getChatPosts()`. `ctx.now` is the call's clock. Breaking: `ctx.storage` is now the snapshot (the old async `get/set/delete/list/clear` was never usable from a synchronous reducer and no plugin used it). `ctx.timer` and `ctx.messages` are deprecated no-ops.
- **Tests:** SDK rules and harness; the activity routes (validation, approvals, refused effects, system actions, lazy timers in order, concurrent reads, the 8-timer limit, ending, storage limits and order, chat after the commit); app messages (author, metadata, mentions, rate limits, bus); the App badge in English and Turkish; the settings channel picker; the worker's envelope and snapshot; the buzzer's timer and leaderboard; the migration.
- **Docs:** PLUGIN_SDK.md (new "Effects" section), PLUGIN_PUBLISHING.md, PLUGIN_UNLOCK_PLAN.md (3a done, and what differs from the plan) and ADR-007.
- **Upgrade notes:** migration 0049 adds `game_sessions.timers` (`jsonb`, default `[]`). It is expand-only; an older image ignores the column, so pending timers just never fire. An installed plugin that asks for `permissions` gets them once it is approved: install the new version through the marketplace dialog.

## [Unreleased] - Marketplace plugins: approved outside sites, and a UI on the author's own site - 2026-10-13

- **Approved origins reach the frame:** a plugin's own UI (`ui: true`) can now use the outside origins the instance owner approved at install. Its page's policy adds them by kind: `connect` to `connect-src` (fetch, WebSockets), `img` to `img-src`, `media` to `media-src`, and `frame` to `frame-src` (no frames otherwise). Only origins the manifest asks for and the owner approved are added. A page framed this way inherits the sandbox (an opaque origin). The HTML is now served `no-cache`, because its policy follows the approval; every other file keeps its year-long cache.
- **A UI on the author's own site:** a plugin with `ui: { "url" }` is framed directly once the owner approved that URL, like a Discord activity. The frame has `sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"` and sends no referrer. The site keeps its own origin, cookies and storage. It cannot navigate the lobby away, open windows or show dialogs.
- **Never the instance's own address:** `allow-same-origin` is safe only for another site. A `ui.url` on the instance's host (any port) or on a subdomain of it is refused at install, by the frame info route and by the lobby.
- **Messages checked by origin:** the lobby accepts a message from an external UI only from its iframe and from the approved origin, and posts to that origin only, never to `'*'`. A frame that navigates to another site hears nothing and is not heard. Protocol v1 is unchanged.
- **The site's address on screen:** above an external UI the lobby shows "Runs on game.example.com" and says the screen is not LobbyForge, in English and Turkish. The frame's accessible title names the site too.
- **The app's policy:** pages add the approved sites to `frame-src`, from a 30-second cache read from the install root and reset on install. API routes and static files get nothing, and nothing is added when the list cannot be read.
- **For plugin authors:** `GET /api/plugin-ui/{id}` now says `mode: "files"` with `src`, or `mode: "external"` with `src` and `origin`. The frame client (`@lobbyforge/plugin-sdk/frame`, and the vendored `lobbyforge-frame.js`) also works on the author's own site: it learns the lobby's origin from the first `init` and exposes it as `lf.parentOrigin`. Re-vendor the client to use a UI on your own site.
- **Tests:** route, middleware and component tests for the policy, the cache and the frame. The browser suite (`apps/web/e2e-sandbox`) adds sites on a second local port. They prove that a files frame reaches its approved API while the policy refuses an unapproved one. They also prove that an approved site speaks protocol v1 under the app's real `frame-src` and cannot reach into the lobby. Another site in the same frame window is neither heard nor told anything.
- **Docs:** PLUGIN_SDK.md ("Your own site as the UI", approved origins, message rules), PLUGIN_PUBLISHING.md, ADR-007 and PLUGIN_UNLOCK_PLAN.md (phase 2 done; what differs from the plan). Host effects (`permissions`) still come with phase 3.

## [Unreleased] - Marketplace plugins: ask for more, and the owner approves at install - 2026-10-12

- **New manifest fields (sandbox-v1):** `network` (outside origins by kind: `connect`, `img`, `media`, `frame`, up to 8 each), `ui: { "url" }` (the plugin's own page as its UI) and `permissions` (`timers`, `storage`, `chat`, `voice.mute`). Origins must be https on a public host: no path, query, wildcard, IP address, `localhost` or local name, so a plugin cannot turn players' browsers into scanners of their own network. Both manifest validators (installer and plugin-worker) share the new cases.
- **Nothing without the owner's approval:** an install that asks for any of these is refused with `409 approval_required` and what it asks for. The marketplace's Install button then shows a dialog (English and Turkish) that says what each item lets the plugin do; on an upgrade it marks what is new. Approving sends `approve` naming exactly the request; less or more is refused, a malformed one is a `400`.
- **Recorded:** the approval is kept in the active version's `active.json` and written to the audit log as `plugin.install` (with the version and what was approved). A plugin only gets what its manifest asks for and the record approved, so a bundle installed earlier with these keys gets nothing until approved.
- **No effect yet:** outside origins and the plugin's own page as its UI arrive with phase 2, host effects with phase 3 (docs/PLUGIN_UNLOCK_PLAN.md, new). Until then an approval is recorded only.
- **For development:** `LOBBYFORGE_PLUGIN_ALLOW_LOCAL_ORIGINS=true` (never in production) accepts `http:` and local hosts, for tests.
- **Docs:** PLUGIN_PUBLISHING.md ("Asking for more than the sandbox", the manifest table and the install step) and the plan, PLUGIN_UNLOCK_PLAN.md.

## [Unreleased] - Voice: back after a reload or a dropped connection - 2026-10-12

- **One click back to voice:** reloading the page used to drop you out of voice with nothing to bring you back. Now the lobby shows "You dropped out of voice. [Back to #channel]" across the top of the centre column. It appears after a reload that comes within 2 minutes of being in voice, and whenever LiveKit gives up reconnecting. It also shows on phones, where the voice footer sits in the closed drawer. Inside a game's activities view the strip is part of the panel. Dismissing it forgets the channel.
- **Automatic return:** after a reload, the lobby rejoins on its own only when the microphone permission is already granted. It joins with the microphone off. Otherwise it waits for the click.
- **No undoing:** leaving voice, joining another channel, another tab taking over, a moderator removing you, the room being deleted, or the server refusing voice (for example a voice block) all clear the way back. Only one tab of a browser rejoins on its own: the tab in voice holds the `lf-voice` Web Lock, so a duplicated tab can't kick it out.
- **Longer reconnecting:** LiveKit now keeps trying for about 2 minutes instead of about 45 seconds. After it gives up, the lobby tries once more when the network comes back and once when you return to the tab.
- **The view comes back:** a reload reopens the activities view you had open instead of the chat.
- **Join from the game:** in a game played over voice (Hushle, Quiz, Vampire Village, Watch Party, voice-only marketplace apps), a viewer outside the voice channel sees "Join the voice channel to play [Join]". A move refused with `voice_required` gets the same Join button. Watching still needs no voice.
- **For developers:** the rules live in `apps/web/lib/voice-resume.ts` (pure and unit-tested). `useActivitySession` also returns `errorCode`, the refusal code behind `error`. Lobby installed apps carry `requiresVoice`. Details are in `docs/VOICE_ROOM.md` → "Coming back after a reload or a drop" and `docs/ACTIVITIES.md` → "Voice in the lobby's activities view".
- **Upgrade notes:** no migration, no new dependency, no server change. Everything is stored in the tab's `sessionStorage` (`lf.voice.resume.v1`, `lf.view.v1`).
## [Unreleased] - Activities: a late press no longer lands on the next card, question or poll - 2026-10-12

- **Hushle:** Got it, Skip, Penalty and Next card name the card they were pressed for. If an opponent's BUST (or a second moderator) moved the card on first, the host's press is ignored instead of scoring a card nobody explained. Every draw now bumps a public deal number, `cardSeq`, and those four actions carry the `cardSeq` of the state the button was drawn from. A counter rather than the card id, because the host who does not play and the explaining team never receive the card; `cardSeq` says how many cards were dealt, never which. BUST keeps its `cardId`.
- **Hushle turns:** Start first turn, End turn and Start next turn carry the public `turnNumber` they were drawn for, and `start-turn` / `end-turn` ignore one that names another turn. A double tap, or the host and a moderator pressing together, now ends one turn; the second press used to end the next team's turn the moment it began (or, on Start first turn, restart the turn with a new card and clock).
- **Hushle, empty deck:** when the host ends a turn on the last card, the next turn starts with no card. Got it, Skip and Penalty used to score that missing card; they are now refused.
- **Quiz:** `answer` carries `questionIndex`, the question the tiles were drawn for. A late or retried answer for question N that arrives after **Next question** is ignored, never recorded against N+1. The host's `reveal` and `next` carry it too: a late Reveal now no longer reveals the next question early, and a second Next no longer skips a question.
- **Poll:** every `open-poll` numbers the poll (`pollId`, kept by `clear-poll`, so it is never reused in a session), and `vote` carries it. A vote cast just before the host cleared the poll and opened a new one is ignored rather than counted on the new poll, whose option ids repeat the old ones. Closing and reopening keeps the same poll and number.
- **Rule for all of them:** an action whose key is present and does not match is ignored silently: the reducer returns the same state, so it changes nothing and is not audited, and the caller gets a plain 200 with the current state (the same as Vampire Village's `phaseId`). An action without the key is accepted as before, so a panel loaded before the deploy keeps working. `null` counts as absent; any other value that is not a non-negative integer is a 400 from the plugin's `validateAction`.
- **Docs:** `docs/HUSHLE.md` (new "Stale presses" section, state v4, the turn moves), `docs/QUIZ.md` (Actions), and a new `plugins/poll/README.md` with Poll's action shapes.
- **Upgrade notes:** no database migration. Hushle state goes to version 4: a running game's row gets `cardSeq: 0` on its next read. Poll gains a `migrateState` that gives a running poll `pollId: 0`. Both values are the same on every read, so a panel that read the migrated state names what the reducer sees. Quiz needs no state change (`currentIndex` was already public).
## [Unreleased] - Marketplace plugin frames: sound, video, WebAssembly and their own files - 2026-10-12

- **Sound and video:** a marketplace plugin's `ui/` may ship `mp3`, `ogg`, `oga`, `opus`, `wav`, `m4a`, `flac`, `webm` and `mp4` files and play them with `<audio>`/`<video>` or the Web Audio API. The asset route answers single `Range` requests with 206, so media can seek (Safari plays nothing without it).
- **WebAssembly:** the frame's policy adds `'wasm-unsafe-eval'`, and `wasm`, `bin`, `data` and `pck` files are served, so a single-threaded engine build (a Godot web export, an Emscripten build) can run. Workers, `eval` and SharedArrayBuffer stay unavailable.
- **Its own files, and nothing else:** `fetch()` and media used to be blocked outright (`connect-src 'none'`). They now reach the plugin version's own folder, `https://<instance>/api/plugin-ui/<id>/<version>/`, and nothing else: not the app's pages or API, not another plugin, not the web. The frame still cannot send what it is shown anywhere. The folder is named from the configured public origin (`LOBBYFORGE_APP_ORIGIN`, else `NEXT_PUBLIC_BASE_URL`), never from the request, because the files are cached publicly. Without either (a dev setup) the policy uses `'self'`; the frame reaches the app without cookies there, and every mutating route refuses a cross-site request.
- **Example:** the sandbox buzzer plays its own `buzz.wav` when you press Buzz.
- **Tests:** the asset route tests cover the new types, ranges and the policy; the browser suite (`e2e-sandbox`) proves that the frame loads its own sound, data and WebAssembly while the CSP refuses the app, its API, another plugin's folder and outside sound.
- **Docs:** ADR-007, PLUGIN_SDK.md ("Marketplace plugin UI": the file-type table, limits and what works) and PLUGIN_PUBLISHING.md. This is phase 1 of the plugin unlock plan; installed plugins need no change.
## [Unreleased] - Activities catch up after a dropped connection - 2026-10-12

- **Missed updates come back:** the realtime gateway keeps no backlog, so a game panel used to miss whatever happened while its socket was down and show a stale state until the next move. The panel now re-reads the session when its socket reconnects, when the network comes back and when the tab is shown again after at least 5 seconds in the background (a phone may have frozen the page).
- **A dead socket is replaced:** when the browser reports the network back, the realtime client replaces a socket that only looks open instead of waiting up to a minute for the heartbeat. Coming back online or showing the tab also lifts the client's 20-attempt circuit breaker, so a laptop that slept reconnects at once.
- **A tap during a network blink still counts:** an action whose answer is lost, or that the server refuses with a retryable 503, is sent again with the same `actionId` for up to 8 seconds; the button stays busy meanwhile. If the first attempt had in fact gone through, the retry comes back as a duplicate and the panel re-reads instead of scoring twice. The error appears only when the server stayed unreachable for the whole window.
- **Every player's voice drop reaches the table:** the LiveKit webhook used to nudge open panels only when the host left or returned. It now nudges for anyone on the session's roster (`publicSummary.presenceChanged`), so panels re-read the session. This is the signal the planned presence rules (away badge, seat timeout) build on.
- **For developers:** `RealtimeClient` has `onReconnect(listener)` (not called for the first connection) and `wake('online' | 'visible')`. `postActivityAction` retries lost answers and retryable 503s (`TRANSIENT_RETRY_WINDOW_MS`, `transientRetryDelayMs`) and throws the network error only when the window is spent. Plugins need no change. Plan: docs/ACTIVITY_INTEGRITY_AND_RESILIENCE.md §4, rows 2, 3 and 5.

## [Unreleased] - Languages: saved per account, default chosen in the admin panel and at setup - 2026-10-12

- **Your language follows you:** Settings → Appearance → Language now saves the choice on your account as well as in the browser, so the desktop app and your other browsers use it too. Pages pick the language in this order: your account's language → this browser's choice → the browser's Accept-Language → the instance default → English. "Match my browser" clears both. Guests save it the same way.
- **Default language in the admin panel:** Community Settings → Overview has a *Default language* list with every installed language, in its own name and with its status. It says which setting is in effect: the saved value, `LOBBYFORGE_DEFAULT_LOCALE`, or English. A save takes effect at once and is written to the audit log.
- **Setup screen:** the setup wizard starts with a *Language* step. It lists every language found on disk and switches the wizard to the one you pick straight away. Finishing setup saves it as the instance default and as the owner's language.
- **Adding a language stays a no-code job:** a language folder added under `apps/web/messages/` shows up in the language picker, on the setup screen and in the admin list on its own.
- **One default everywhere:** the built-in bots' default texts, emails and the sandboxed plugins' language hint all use the same instance default (the saved value, then `LOBBYFORGE_DEFAULT_LOCALE`, then English). The plugin hint used to skip it and fall back to English.
- **Emails:** an email goes out in the recipient's saved language, else in the language of the request that sent it, else the instance default. An explicit English choice now counts. A password reset is written in the account owner's language, not the language of whoever asked for it.
- **API:** `GET`/`PATCH /api/settings/me` carry `locale` (an installed code, or `null` to follow the browser). New `GET`/`PUT /api/admin/default-locale` (instance owner only). `POST /api/setup/complete` takes an optional `locale`.
- **For developers:** `localeFromRequest(req)` (`lib/i18n/server`, `lib/i18n/request-locale`) is now async, because it can read the account's saved language. The instance default is resolved only by `lib/i18n/instance-default.ts`: `getInstanceDefaultLocale()` (async) and `instanceDefaultLocale()` (sync, cached). `mailLocale` and `preferredMailLocale` are async; `requestLocale` is gone (use `localeFromRequest`).
- **Upgrade notes:** migration 0048 makes `users.locale` nullable with no default, and clears every existing `'en'` to NULL ("never chose"), because nothing used to write that column. It also adds `instance_settings.default_locale`. Rolling back the app is safe: the previous image treats NULL like English. `LOBBYFORGE_DEFAULT_LOCALE` is documented in both `.env` examples and passed through by the dev compose file; a saved default overrides it. `servers.default_locale` is still unused.

## [Unreleased] - Marketplace plugins: names and summaries in every language - 2026-10-12

- **Marketplace plugins:** a sandbox-v1 bundle can ship `locales/<code>.json`, the same tables official plugins ship. Its `catalog.name` and `catalog.summary` name and describe the plugin in each viewer's language in the lobby's activity list and header, the community app list, the room picker and the instance's marketplace page. `manifest.json`'s `name` stays the fallback. Adding a language to a marketplace plugin is one more file, no code. See `docs/PLUGIN_PUBLISHING.md` → `locales/`.
- **Validation:** the installer refuses a bundle with a malformed locale file as a whole and names the file and the problem. Only `<code>.json` files directly in `locales/`, flat string values, at most 32 files of 128 KiB (1 MiB together) and 1000 keys each; `catalog.name` is one line of at most 80 characters, `catalog.summary` at most 200. The same check runs on every load and in the plugin worker; both manifest validators share one case table. `"locales"` in `manifest.json` is optional with locale files, and when given must list exactly their codes.
- **Safety:** a bundle can never rename a compiled-in plugin: tables for an official plugin's id are ignored. A new version's tables replace the old version's.
- **Activities:** a marketplace plugin's `createInitialState` now gets the starter's language in `ctx.locale`. It always got `en`.
- **Plugin SDK:** the locale registry is one per process (it lives on `globalThis`), so tables the host registers in one Next chunk are seen in every other. New `replacePluginLocale(pluginId, tables)` swaps all of a plugin's tables at once.
- **API:** `GET /api/servers/{id}/apps` returns each app's `catalog.summary` already in the viewer's language. The room picker and the server settings translate in the browser, which never sees a marketplace bundle's tables.
- **Example:** `examples/plugins/sandbox-buzzer` ships `locales/en.json` and `locales/tr.json` ("Buzzer" in both; the summary is translated). `pack.mjs` packs `locales/` and checks it first.
- **Upgrade notes:** no migration. Bundles without `locales/` show as before. An installed bundle that already has a `locales/` folder is now validated when it loads; if it breaks a rule it stops loading, and the log says why.

## [Unreleased] - Bots speak each viewer's language - 2026-10-12

- **Built-in bots:** the Welcome Bot's default greeting, the Moderation Bot's default notice and both bots' default names now show in each member's own language. A Turkish member reads "Ayşe, Oyun Gecesi sunucusuna hoş geldin!" while an English member reads "Welcome to Oyun Gecesi, Ayşe!" in the same channel. A greeting, notice or name an admin writes is shown to everyone exactly as written. Before, the bots spoke only the instance language.
- **How it's stored:** the message is still stored once, with its text in the instance language as the fallback for bots, search and older apps. A default text also carries `metadata.i18n = { key, params }`, which the lobby renders in the viewer's language, both on first paint and live. `i18n` is a reserved metadata key: members can't send it, and the lobby only honours it on a bot's message and for the two default-text keys. A name is the default when it equals a shipped default name in any installed language. Message and bot payloads to browsers then carry `bot.defaultName: true`. Nothing new is stored for names.
- **Bot settings:** the name, greeting and notice fields are still pre-filled with the defaults in your language, but a field is only sent once you edit it. Before, one save stored the defaults in the admin's language as if the admin had written them. Each field now says whether members see it in their own language or exactly as written, and "Use the default" switches back. `PUT …/bots/builtin/{type}` takes `name: null` for the default name.
- **Existing bots:** a stored greeting or notice that is word for word a shipped default (in any installed language) counts as the default, with no migration. It's read as the default at once, and stored as `null` with the next save that changes something.
- **Bot API v2:** `interaction_create` carries `locale`, the invoker's language code (their choice, else their browser's, else the instance default). It's on the event stream and on HTTP endpoint deliveries alike. The field is additive and optional in the SDK types; older instances don't send it. `docs/BOT_API_V2.md` has a new "Languages" section.
- **SDK:** the `/roll` examples answer each member in their language ("Ayşe rolled 18 (20-sided)" / "Ayşe 18 attı (20 yüzlü)"), with the strings in `examples/roll-text.mjs`. The `locale.ts` doc example now uses `interaction.locale` instead of a `ctx.locale` that never existed.
- **Upgrade notes:** no migration and no new dependency.

## [Unreleased] - The desktop app and its installer speak every shipped language - 2026-10-12

- **Connect screen and tray:** their text now lives in `apps/web/messages/<code>/desktop.json` (en, tr). The app picks the system's language and falls back to English. The tray menu gets its labels from the connect screen and keeps them for the next launch.
- **Errors:** the Rust side refuses an address with a stable code (`invalid_url`, `https_required`, `no_extras`, `origin_only`) and the screen shows it as a sentence in the user's language. Anything else shows a generic message.
- **Windows installer:** its NSIS language list is generated from the language folders, for every language Tauri's installer can translate. English stays first, as the fallback.
- **No code to add a language:** `pnpm i18n:sync` (run by `pnpm i18n:add`) writes `apps/desktop/dist-shell/locales.js` and the installer list. `pnpm i18n:status` and the desktop tests fail when either is stale.

## [Unreleased] - Choose who can create polls from the Poll app's card - 2026-10-10

- **Poll app card:** Community settings → Apps → Poll now has "Who can create polls in text channels": a box per role, highest first. Each box is the role's `create_polls` permission, saved through the role API, so the role editors show the same thing and the role API's checks (Manage Roles, hierarchy, no granting what you lack) apply. Administrator roles show ticked and locked; without Manage Roles the card is read-only and says why. A refusal is explained inline.
- **Settings access:** the community settings guard now also reports `manageRoles`.

## [Unreleased] - Polls in text channels - 2026-10-10

- **Polls:** members with the new **Create polls** permission can post a poll in a text or announcement channel from the composer's `+` menu: a question, 2–10 answers, optional multiple answers, and a duration from 1 hour to 7 days. Anyone who can read the channel can vote, change or remove their vote until the poll closes. Counts show after you vote, and to everyone once the poll closes; before that you see how many people voted. The creator or anyone with Manage Messages can close a poll early. Closed polls stay in the channel with their final results. The voice-room Poll activity is unchanged. See `docs/CHAT_POLLS.md`.
- **Anonymous results:** no API answer or realtime event ever says who chose what — not to the creator, moderators or the owner. The database stores each voter's choice (so a vote can be changed and shows on every device); votes are never written to the audit log.
- **Same rules as a message:** posting a poll goes through email verification, member timeouts, Mention Everyone, the Moderation Bot (question and answers together) and the message rate limit. Voting is gated like a reaction. A poll's question can't be edited, and deleting the message deletes the poll and its ballots.
- **Permission:** `create_polls` is in both role editors. New servers give it to the Owner role; migration 0047 adds it to existing roles that have `administrator` or `manage_messages`, never to @everyone.
- **API:** `POST …/channels/{channelId}/polls`, `GET …/polls/{pollId}`, `PUT`/`DELETE …/polls/{pollId}/vote`, `POST …/polls/{pollId}/close`. Message lists attach each poll for the viewer in one batched query. `poll` is a reserved message metadata key. Bots see a poll as a plain message (its question).
- **Lobby:** edits and deletes now show up live. The chat feed used to drop `message_update` and `message_delete`, so both only appeared after a reload.
- **Realtime:** the browser client re-sends its subscriptions when the gateway says `hello`. The gateway drops a subscribe that arrives before it has authenticated the socket, so on a slow sign-in check a lobby could get no live updates at all until a reload.
- **Upgrade notes:** migration 0047 is expand-only: two new tables (`message_polls`, `message_poll_votes`) and the permission backfill.

## [Unreleased] - Activity and lobby polish - 2026-10-10

- **Vampire Village:** on the end screen, losers' role and status columns line up with the winners' (every row keeps the badge's width). The dawn light now sweeps inside the stage instead of sliding over the header.
- **Hushle:** the team name above the card says what it is: "Explaining: Eagles" / "Anlatan: Kartallar". An opponent no longer reads it as their own team.
- **Dice:** buttons say "Roll a 6-sided die" / "6 yüzlü zar at" instead of "d6", and the last-roll line keeps "· 5" on the same line on a phone.
- **Slash commands:** an offline bot's commands stay in the `/` list but greyed out, after the online bots, under "Offline". The keys and clicks skip them, and Enter no longer posts `/roll` as text when every match is offline. `GET /api/servers/{id}/commands` adds `bot.online`.
- **App names:** every screen that names an activity uses the plugin's own `catalog.name` in the viewer's language: the community app list (server settings and the old room picker), the admin app list, and the hub's landing, home and marketplace pages. In Turkish, Dice Bot is now "Zar"; established names (Quiz, Watch Party, Hushle) stay.

## [Unreleased] - Dependency cleanup, fourth batch - 2026-10-10

- **Email:** nodemailer 10.0.14 (exact pin). It carries the fixes for GHSA-4ffr-jq9g-5ffx (SMTP AUTH regex DoS, high) and GHSA-g73g-hqqh-jr95 (malformed envelope recipient), both fixed in 10.0.13. It also includes 10.0.12's fixes: a bare CR in the message becomes CRLF, which blocks SMTP smuggling, and a send on a dead pool connection now settles instead of hanging. 10.0.14 is the newest release at least a week old.
- **Voice:** livekit-server-sdk 2.19.1.
  - Webhook verification now requires an `exp` claim. LiveKit always sets one.
  - The SDK hashes with Web Crypto only.
  - API calls carry an `X-Livekit-Request-Id`.
  - Region failover applies only to `*.livekit.cloud` hosts, so self-hosted LiveKit is unaffected.
  - The token, webhook, moderation and voice-presence paths use the same calls as before.
- **Realtime:** ws 8.22.0 in the gateway (adds a `protocols` option, and a `close()` call with invalid arguments no longer moves the socket to `CLOSING`), with @types/ws 8.18.2.
- **Dev:** Vite 8.3.4, the test runner's engine. It uses Rolldown and Oxc, and fixes three dev-server advisories (GHSA-rq7h-c2jc-7f22, GHSA-vfpm-58rq-9qcg, GHSA-9jrq-w75r-8gcw). The web test config moves from `esbuild.jsx` to `oxc.jsx`.
- **CI:** pnpm/action-setup v6.1.0, still SHA-pinned. It bootstraps with pnpm 11 and then switches to the pinned 10.12.1, which matches `packageManager`.
- **Held back, with a Dependabot ignore rule:** Tailwind CSS 4. It's a new engine with a CSS-first config, so it moves with tailwind-merge 3 in one migration. `docs/TAILWIND4_MIGRATION.md` has the assessment and an estimate.

## [0.2.0-rc.8] - 2026-10-09

The first release candidate since rc.7. It collects everything below, down to and including "official hub, plugin UI kit, finished games and bots".

- **Security:** a full security review was carried out and every finding fixed, including the follow-ups.
  - Device cookies stop password guessing from locking out an account's owner.
  - Voice moderation adds a disconnect action, and every anti-cheat block is written to the audit log.
- **Bots:** Bot API v2, with slash commands, incoming and outgoing webhooks, an event stream and SDK v2. Developer docs are on the hub at `/developers`.
- **Marketplace:** plugins run in a sandbox, with QuickJS on the server and a sandboxed iframe for the UI (ADR-007).
- **Bot protection:** the built-in ALTCHA challenge is on by default. Cloudflare Turnstile and Google reCAPTCHA can be used instead.
- **Email:** one provider registry (SES, Scaleway, the free tiers, custom SMTP), email verification (`off` / `optional` / `required`), email change, and password reset.
- **Activities and bots:**
  - fixes from a real-browser pass with several players:
    - phone navigation;
    - voice is now enforced;
    - hosting moves to someone else when the host leaves;
    - "play again";
    - `bot_offline`;
  - fixes from the full end-to-end pass, including expiring invites and concurrent actions.
- **Admin pages** are hidden from anyone who can't use them (404), and so are their entry points.
- **Operations:** `lfctl user reset-password` / `list-admins` recover an admin account from the server shell. Signed manifests set their channel by version.
- **Desktop:** a one-click, branded, per-user Windows installer (English and Turkish). The code-signing pipeline turns on once secrets are added (`docs/DESKTOP_SIGNING.md`).
- **Dependencies:**
  - React 19.3, ESLint 10, Vitest 5;
  - Redis 8.10.2 (**back up `redis-data` before upgrading**: Redis 8 rewrites the file in a format Redis 7 can't read);
  - current GitHub Actions;
  - zod 4 and tailwind-merge 3 are deferred.
- **Upgrade notes:**
  - migrations 0039–0046 are expand-only;
  - bot protection (ALTCHA) turns on for sign-up and new guests, and `LOBBYFORGE_CAPTCHA_PROVIDER=none` restores the old behaviour;
  - email stays off until it's configured.

## [Unreleased] - Admin pages are invisible to people who cannot use them - 2026-10-09

- **No more "admin token required" screens.**
  - **Who gets a 404:** signed-out visitors, guests, and members without the permission get a real 404 on every `/admin/**` page and on `/servers/{id}`. The check runs on the server before any settings UI or data loads, so refused pages are indistinguishable from missing ones.
  - **Where the guard lives:** `lib/admin-access.ts` holds one guard per section. A source-shape test makes every admin page go through it.
- **Each page opens with the permission its API already enforces:**

  | Page | Opens with |
  |---|---|
  | Members | Kick, Ban or Manage Roles, or Manage Community for the join queue |
  | Channels | Manage Channels |
  | Roles | Manage Roles |
  | Invites, Voice & Media, Apps, Bots | Manage Community |
  | Audit | View Audit Log |
  | Instance-wide pages | the instance owner or the operator token only |

- **Entry points:**
  - The community menu, voice-channel gear, channel hints, "install an app", the bot profile link and the header Health link appear only when their page would open.
  - The settings navigation lists only the sections the viewer may open.
  - Inside Members, moderators see only the actions they hold.

## [Unreleased] - One-click Windows installer and code-signing pipeline - 2026-10-09

- **Windows installer, one click.** Double-clicking the setup.exe installs for the current user in `%LOCALAPPDATA%\LobbyForge` with no admin prompt, shows a small dark LobbyForge progress window for a second or two, and opens the app. Running a newer installer closes the app, updates it in place (shortcuts and settings kept) and reopens it. `/WIZARD` brings back the classic pages; `/S` stays silent and does not open the app. English and Turkish, picked from the Windows display language.
  - **How:** a copy of Tauri's NSIS template (tauri-bundler 2.9.4, the one in `@tauri-apps/cli` 2.11.4) with four fenced changes: brand colours, passive mode by default, a compact progress window, and launch when done. Uninstall, upgrade, WebView2 and shortcut code is Tauri's. A unit test fails when the CLI version changes, as a reminder to rebase it (docs/DESKTOP_SIGNING.md, "Upgrading Tauri").
  - **Artwork:** `apps/desktop/scripts/make-installer-assets.mjs` draws the NSIS header and sidebar, the WiX banner and dialog image and the installer icon from the app icon in the web theme's dark colours.
- **Code signing, off until configured.** `desktop-release.yml` signs Windows and macOS builds as soon as their secrets exist in the `desktop-signing` environment; without secrets builds stay unsigned and green, and half a configuration fails the build. Windows: Certum SimplySign through `ssign` (app, uninstaller and installer), or SignPath Foundation through its action (app exe, then installers). macOS: Developer ID signing and notarization (`APPLE_*`). Secrets reach only the bundling step, never the compile step, and are never printed; signatures are verified after bundling. A release-day fallback signs on the owner's machine with a one-time SimplySign code: `pnpm --filter @lobbyforge/desktop sign:local`. Owner steps: docs/DESKTOP_SIGNING.md; ADR-005 amended.
- **One desktop pipeline.** `release.yml` now calls `desktop-release.yml` for `v*` tags instead of building installers itself, passing only the signing secrets by name. The tauri-action build on `desktop-v*` tags is retired; desktop releases ship with the unified `v*` release. A platform whose build or signature check fails uploads nothing, so it is missing from the release instead of published unsigned; the server release still goes out. Linux bundles are now built on Ubuntu 22.04 for both entry points.
- **Metadata:** publisher "LobbyForge contributors" (it was "LobbyForge", the product name, which the Microsoft Store rejects), copyright, homepage `https://lobbyforge.org`, licence and clearer descriptions. The exe's company name and copyright follow.
- **Windows smoke test fixed.** It looked for `LobbyForge.exe` in `%LOCALAPPDATA%\Programs\LobbyForge`; the app is `lobbyforge-desktop.exe` in `%LOCALAPPDATA%\LobbyForge`, so even its fallback search could not find it. It now reads the install folder and binary name from the per-user uninstall entry (with both folders as fallbacks), asserts a per-user install, and also reinstalls and uninstalls.

## [Unreleased] - Account recovery from the server shell - 2026-10-09

- **`lfctl user reset-password --email <address>`** resets a forgotten password from the server's shell. Until now, an install without mail had to wipe its database to recover a forgotten admin password.
  - **The password:** asked twice without echo, read with `--password-stdin`, or created with `--generate` and printed once. A password on the command line is refused.
  - **Same code as the app:** it runs inside the web container with the app's own scrypt hash and sign-up policy.
  - **Afterwards:** every session and device cookie is revoked, and so are pending email-change/reset links and desktop sign-in codes. The sign-in lock is cleared and the reset is audited as `user.password_reset_by_operator`.
- **`lfctl user list-admins`** shows the owner's and the server owners' addresses. See docs/GUEST_AUTH.md, "Account recovery by the server operator".

## [Unreleased] - Dependency cleanup, third batch - 2026-10-07

- **Runtime:** drizzle-orm 0.45.3. Apart from a new Netlify driver, its published code is identical to 0.45.2. The ALTCHA widget moves to 3.2.4: the workers, CSS and types are unchanged, and the external build only stops an interrupted audio challenge from throwing an unhandled `AbortError`.
- **Release pipeline:** docker/setup-buildx-action v4, docker/login-action v4, docker/metadata-action v6 and docker/build-push-action v7, moved together and still pinned to commit SHAs. Dependabot now groups `docker/*` actions.
- **Dev:** @testing-library/user-event 14.6.7, and @types/node 22.20.5.
- **Held back, with Dependabot ignore rules:**
  - tailwind-merge 3 supports only Tailwind CSS 4; it moves with the Tailwind 4 migration.
  - @types/node 23 or later; the types follow the Node 22 runtime.

## [Unreleased] - Activities and bots: fixes from a real-browser pass - 2026-10-07

A pre-beta pass played every activity with 4–7 browser players and drove the bots the way an owner would. Hidden information held everywhere. These were the bugs:
- **Phones could not reach channels or voice.** The navigation drawer was empty because the sidebar was desktop-only. The drawer now holds the whole sidebar: servers, channels, voice, activities and DMs. It is a proper modal, with focus trapped inside and Escape to close, and it closes when you pick something. Phone headers no longer put the menu button over the channel icon.
- **Voice-only activities enforce voice.** In Hushle, Quiz, Vampire Village, Watch Party and the sandbox buzzer, player and member actions now need the actor in the voice room (`403 voice_required`). Host actions and reading the state never need it, and Poll and Dice stay open to everyone. Plugins declare this with `requiresVoiceRoom` / `allowOutsideVoice`, documented in `docs/PLUGIN_SDK.md`.
- **A host who leaves no longer locks the channel.**
  - **Transfer:** after 60 s out of voice, hosting moves to the longest-present participant (audited as `activity.host_transfer`).
  - **Abandoned session:** after 3 minutes, or when nobody can take over, any voice participant can end it.
  - **How it runs:** the check is lazy on the server, and the panel re-reads at the due time, so it happens without anyone acting. Watch Party's own host follows the session host.
- **Play again:** plugins declare `restartActions`. Hushle's "Start new game" works now, and Quiz gained "Play again".
- **Hushle:** a BUST names its card, so two players pressing it together cost one penalty, not two plus a burned card.
- **Two simultaneous starts** answer `409 activity_exists` with the running session instead of a 500, and the UI offers to open it.
- **Names:** an emoji at the start of a display name no longer breaks avatars or throws a React hydration error; initials use whole graphemes everywhere. LiveKit tokens now carry the member's name, so late joiners show their name, or "Unknown member", never a user id.
- **Bots:**
  - A slash command to a bot with no live connection and no HTTP endpoint answers `409 bot_offline` at once, instead of "thinking…" for 15 minutes.
  - `roll-bot.mjs` explains a taken command name or a revoked token, and exits non-zero.
  - **bot-sdk reconnects through a gateway restart.** Node 22's built-in WebSocket fires `error` and no `close` when the handshake fails while the gateway is still starting. The SDK waited for a close that never came, and the bot exited 0. Now an error with no close within 1 s counts as a 1006 close, and a socket that doesn't open within 15 s is retried.
  - The coming-soon bot permissions can't be selected.
  - The Welcome Bot says why an @everyone template is refused.
- **Errors and text:**
  - Activity and slash refusals carry a `code` (`session_ended`, `not_host`, `voice_required`, `activity_exists`, `rate_limited`, `bot_offline`, `wrong_phase`, `not_player`), shown as translated sentences.
  - Activity names are localized ("Anket").
  - Marketplace apps get a "Marketplace" badge.
  - Watch Party says a link is too long instead of "not a YouTube link".
- **Rate limit:** activity actions are limited per user per session (90/min), with a per-address backstop, so a household or LAN no longer shares one budget.

## [Unreleased] - Dependency cleanup, second batch - 2026-10-07

- **Icon font:** material-symbols 0.47.6. Every icon name the app uses is still a ligature in the new font (checked by rendering both fonts).
- **Mailpit v1.31** (security fixes from v1.30 onward), digest-pinned. The dev compose file now uses the same pin as the e2e mail overlay instead of `latest`.
- **Tests:** @testing-library/jest-dom 7 (it now requires `@testing-library/dom`, which apps/web declares directly), @testing-library/react 16.3.3, @testing-library/dom 10.4.2. drizzle-kit 0.31.11.
- **CI:** actions/upload-artifact v7 with actions/download-artifact v8, and CodeQL Action v4 for both `init` and `analyze`. Dependabot now groups these pairs, plus the Testing Library packages, so they arrive in one PR.

## [Unreleased] - Dependency and security cleanup - 2026-10-07

- **Redis 7.4 → 8.10** (`redis:8-alpine`, digest-pinned in the production and development compose files and in the Trivy scan). Redis 8 is also available under AGPLv3. **The upgrade is one-way:** Redis 8 loads the existing `redis-data` volume, but rewrites it in a format Redis 7 can't read. Back it up before updating:
  - `docker compose -f infra/docker/docker-compose.prod.yml stop redis`
  - `docker run --rm -v lobbyforge-prod_redis-data:/data -v "$PWD":/b alpine tar czf /b/redis-data-7.4.tgz -C /data .`
  
  To roll back, restore that archive together with the old image. Don't empty the volume to get past a failed start: session revocations and voice blocks are stored only in Redis.
- **nginx** 1.31-alpine moves to the rebuilt image digest.
- **Advisories:**
  - `postcss-selector-parser` is overridden to 7.1.6 (GHSA-rj75-hqrm-r3gf); Tailwind 3's CSS output is byte-identical.
  - `sharp` is refreshed to 0.35.5 inside next's existing range (GHSA-wq5f-xc86-pv6w).
- **`lfctl`:** a release manifest's `version` and `minimumVersion` must be strict semver as soon as the manifest is loaded, and `.env.prod` writes refuse multi-line values.
- **Toolchain:** ESLint 10, typescript-eslint 8.71, Vitest 5, React 19.3. Node 22.13 or later is now required to develop (`engines`).
- **CI:** actions/checkout v7, actions/setup-node v7 and tauri-action v1, still pinned to commit SHAs.
- **zod stays on 3.x for now.** Moving to zod 4 changes the validation messages clients receive and needs its own migration, so Dependabot ignores zod major versions until then. The plan and the trial-upgrade findings are in `docs/ZOD4_MIGRATION.md`.

## [Unreleased] - Email: verification, email change and password reset - 2026-10-05

### Email (`docs/EMAIL.md`)
- **One mail layer, one provider registry** (`apps/web/lib/mail/`).
  - Every provider is a preset over a single transport interface. v1 ships SMTP through nodemailer 10.0.11 (exact pin; every advisory is fixed at or below 10.0.9).
  - Presets, checked against each provider's docs:
    - Professional: Amazon SES (default region eu-central-1) and Scaleway TEM.
    - Free tiers: Brevo, SMTP2GO, Resend, Mailjet, Mailgun, Gmail.
    - Custom SMTP.
    - Mailpit for development (never offered on the official hub).
  - Adding a provider means one registry entry plus its message keys; an HTTP API transport would be a new `transport` kind.
- **Admin → Settings → Email:**
  - the provider picker, with each free tier's limits, a KVKK note for relays abroad and a docs link;
  - a write-only password (AES-256-GCM, through the new `lib/secret-box.ts` shared with CAPTCHA). Changing provider, host or username asks for it again, so a saved password never goes to another server;
  - a daily limit;
  - a test email with classified results;
  - the verification mode and scope, and disposable-domain blocking with allow and block lists.
  
  `LOBBYFORGE_SMTP_*` / `LOBBYFORGE_MAIL_*` / `LOBBYFORGE_EMAIL_VERIFICATION` win over the database and show as locked.
- **Email verification: `off` (default) / `optional` / `required`.**
  - One email carries a 6-digit code (15 min, 5 attempts reserved atomically) and a link (24 h). The link's GET only shows a page; the button's POST verifies, so mail scanners can't use it up.
  - A lobby banner offers code entry, resend with a countdown, and change email.
  - **`required`:**
    - it restricts only new accounts in scope (open sign-up by default; invite sign-ups are trusted like with CAPTCHA). It never restricts the owner, admins, guests, or accounts from before enforcement unless the admin sets a deadline;
    - restricted accounts can read and manage their account, but not send messages or DMs, join voice, create servers, channels or invites, upload images, create bots or webhooks, add a join-request note, or publish on the hub. All of these answer `403 email_unverified`, and the UI shows a "verify" notice;
    - it can't be saved without a passing test of the saved settings from the last 24 hours, or with no working transport.
- **CAPTCHA:** an invisible Turnstile or reCAPTCHA that blocks now says "The security check failed. Reload the page and try again." instead of asking to complete a check that isn't on screen.
- **Email change:**
  - needs the current password;
  - the code goes to the new address, and the old address gets a notice;
  - other sessions are revoked;
  - any pending reset is invalidated.
- **Password reset** (`/forgot-password`, `/reset-password`, by link or code):
  - it has a new CAPTCHA surface, `password_reset` (on by default);
  - the answer is the same whether the account exists or not;
  - the reset only works while the address is unchanged;
  - it revokes every session and device cookie.
  
  Any password write deletes pending email changes and resets, so an attacker who started a change can't finish it after the owner resets.
- **Rate limits:** sends are limited per account, per recipient address and per client address. Each send takes its limits atomically, and verification and change emails have separate cooldowns.
- **Disposable domains:** a vendored CC0 list of about 9,200 domains, refreshed with `scripts/update-disposable-domains.mjs`.
- **Doctor:** critical when `required` has no transport, or when sends keep failing; plus warnings for failed tests, near-limit sends and undecryptable passwords; hints for port 25, Gmail in production and missing SPF/DMARC.
- **Migration 0046:**
  - adds `users.email_verified_at` (backfilled from Google sign-ins whose Google address matches the account) and `users.signup_channel`;
  - adds the `email_tokens` table;
  - adds the mail settings on `instance_settings`.
  
  It is expand-only; nothing changes until an admin configures mail.

## [Unreleased] - Fixes from the full end-to-end pass - 2026-10-04

- **Invites with an expiry redeem again.** The redeem locked the invite with a raw `tx.execute`, which returns `expires_at` as a string, so every invite with an expiry (redeem and sign-up through it) failed with a 500. The lock now goes through the query builder; the route logs unexpected failures (JSON-quoted, without the code). Real-Postgres test: valid → redeemed, expired → refused.
- **Concurrent activity actions all apply.** 8 players rolling at once used to get 3–4 successes and 409s for the rest (3 optimistic CAS attempts). Read → reduce → write now runs under a per-session transaction-scoped advisory lock (`withGameSessionWriteLock`), with an in-memory queue per connection pool in front of it; a 409 is left only for a lock not granted within 10 s.
- **Directory reports** answer 404 for an id the directory does not know.
- **A hidden online status** also hides the voice channel and the activity; the per-channel presence list leaves such users out.
- **`lfctl update check` without `--manifest`** finds the newest release of the channel through the GitHub API (`/releases/latest` skips pre-releases and answered 404). `--channel stable` takes full releases only; `--channel beta` follows pre-releases. Forks: `LOBBYFORGE_RELEASE_REPO`.
- **k6 load test:** target from `BASE_URL` / `LF_E2E_BASE_URL`, an Origin header on POSTs, bounded guest creation, and a clear stop when bot protection asks for a challenge (run it with `LOBBYFORGE_CAPTCHA_PROVIDER=none`).
- **Activity conflicts retry in the client.** A 409 marked retryable is sent again with the same `actionId` (jittered backoff, about 2 s at most). Only if every attempt loses does a translated message appear.
- **Join page errors are translated:** expired, used up, banned, rate limited, or generic. Raw status text is no longer shown.
- **A voice click made while the lobby is still looking up the session** waits for the lookup instead of showing "Session not ready". The settled guest is read from a ref, so a click just before React re-renders sees it. After a failed lookup, the next click looks again, so "try again" works without a reload.
- **Release manifests for a pre-release version** (`0.2.0-rc.8`) now say `channel: "beta"` instead of `"stable"`; `--channel` still overrides it.
- **Accessibility:**
  - offline members fade only their avatar, so names keep their contrast;
  - the light theme's `text-muted` is `#5f6b7b` (AA on every light surface it sits on; it was 3.9–4.3:1);
  - the settings and server pages' scroll regions can be focused by keyboard;
  - an offline member's dot reads "Offline" instead of the self-status "Invisible".
- **Postgres error codes are read through Drizzle's wrapper** (`pgErrorCode` / `isPgUniqueViolation` in `@lobbyforge/db`). The invite-code collision retry never fired, because Drizzle 0.45 puts the driver error in `cause`.

## [Unreleased] - Bot protection - 2026-10-04

### Bot protection (`docs/CAPTCHA.md`)
- **Built-in ALTCHA is on by default** for open sign-up (including the official hub) and for creating a NEW guest identity.
  - The browser solves a small PBKDF2/SHA-256 proof of work, with no third party, no cookie and no internet needed.
  - On a plain-HTTP origin, where Web Crypto is missing, a pure-JS solver takes over, so LAN installs keep working.
- **Sign-in is adaptive.** A challenge appears only after 3 failures for the account (configurable), when the client address is over its threshold, or in attack mode. A browser holding a device cookie that still matches the current password hash is never asked.
- **Optional providers:** Cloudflare Turnstile and Google reCAPTCHA (v2 checkbox, v2 invisible or v3 score).
  - Keys come from `LOBBYFORGE_CAPTCHA_*` or from the admin card. The secret is stored with AES-256-GCM and never goes back to a browser.
  - If the provider is unreachable, the instance falls back to ALTCHA. An attacker cannot choose the fallback while the provider is healthy, or trigger it with a refused secret.
- **Admin → Settings → Authentication → Bot protection:**
  - provider choice;
  - each surface switched on or off (sign-up, invite sign-up, new guests, sign-in: never / when suspicious / always);
  - difficulty, Turnstile appearance and the reCAPTCHA v3 score;
  - attack mode, automatic (over 50 failed sign-ins in 10 minutes, for 30 minutes) or manual;
  - a "Test configuration" button;
  - a privacy dialog with a ready paragraph for the privacy notice (en/tr).
  
  Saving is audited as `instance.captcha_updated`, with field names only.
- **An invite never removes the check.** On an open instance it can only add protection; otherwise a member could mint an unlimited invite and skip the challenge.
- **Phase-0 limits:**
  - a separate new-guest limit (10/h per address, or a 200/h instance-wide backstop when addresses are unknown);
  - a `website` honeypot;
  - a single-use form token with a 2-second minimum fill time;
  - single-use ALTCHA solutions, bound to their surface.
- **Behind the scenes:**
  - `middleware.ts` runs in the Node runtime and adds an external provider's CSP origins to every page while that provider is active. Client-side navigation keeps the first page's CSP.
  - The middleware serves cached settings and refreshes them in the background.
- **Doctor:** checks for missing or undecryptable keys, refused secrets, test keys in production and Redis, plus `secure_origin` (critical when ALTCHA is active on a plain-HTTP origin).
- **Upgrading:** migration 0045 turns ALTCHA on for sign-up and new guests. `LOBBYFORGE_CAPTCHA_PROVIDER=none` restores the old behaviour.

### Other
- **Signed-out settings pages** redirect to `/login?next=…` instead of minting a guest. `/login` returns there after any sign-in method; `next` is limited to same-origin paths.
- **Auto-guest flows** (room page, lobby voice) show a "One quick check" dialog instead of failing.
- **The voice roster "⋮" menu** renders in a portal, so a running sidebar animation can no longer cover it.
- **Email verification research:** `docs/EMAIL_VERIFICATION_RESEARCH_2026-10.md` covers the decision and plan; nothing is built yet.

## [Unreleased] - Bot API v2 and sandboxed marketplace - 2026-10-03

### Accounts and sessions
- Device cookies for the sign-in limit (OWASP "Slow Down Online Guessing
  Attacks with Device Cookies"). Anyone who knew an address could keep its
  owner locked out in 15-minute stretches. Now a successful sign-in on the
  login form or the desktop handoff start sets `lf_device`: HttpOnly,
  SameSite=Lax, Secure in production, 180 days. It is MAC'd with a key
  derived from the session secret and holds up to 5 accounts per browser,
  stored as an HMAC of the email plus a random nonce, never the address.
  When an attempt carries a valid entry for the submitted email, it is
  counted in that device's own bucket (10 failures / 15 min) and is not
  refused by the account-wide lock. Its success never clears the account
  counter. A device whose bucket fills is untrusted until its window ends
  and falls back to the account counter. Each entry is also bound to the
  account's password hash at issue time (an HMAC of the email and the
  hash, under its own derived key), so a password change, which is also
  how a user signs out everywhere, voids every device entry of that
  account. The binding is checked after the account lookup and before
  the password check. An attempt with a stale entry is charged to the
  account-wide counter and refused while the account is locked, so
  whoever signed in once with the old password keeps no bucket of guesses
  for the remaining 180 days. A fresh sign-in issues a new entry, and the
  check does the same work for unknown emails. Everything else is
  unchanged: attempts without a valid cookie (none, forged, expired,
  another account's) are counted before anything is looked up, unknown
  and known emails get identical responses (unknown emails never earn a
  cookie), key names are HMAC'd, and a Redis outage in production fails
  closed. See "Device cookies" in `docs/GUEST_AUTH.md`.

### Marketplace
- Marketplace plugin code runs sandboxed (ADR-007, server side). A bundle
  is now `sdk: "sandbox-v1"`: `manifest.json` + `server.js` (+ optional
  `ui/`). `server.js` is plain JavaScript that assigns `globalThis.plugin`
  and runs in a QuickJS WebAssembly VM (`quickjs-emscripten-core` +
  `@jitl/quickjs-wasmfile-release-sync` 0.32.0, pinned) inside the plugin
  worker: no `require`/`import`, `process`, file system, network, timers
  or shared memory, and no host functions at all. Every call gets a fresh
  WebAssembly instance, 32 MB of VM memory, a 256 KiB VM stack and the
  call budget (`PLUGIN_CALL_BUDGET_MS`, now 2 s by default) as an
  interrupt deadline; the result stays capped at 4 MiB. The interrupt is
  checked between bytecodes, so a long native operation could overrun it
  (measured: `sort()` in a loop ran 298 s past a 300 ms deadline) — calls
  therefore run in a small pool of executor threads that are terminated
  shortly after the budget. A runaway plugin fails its call, never the
  worker. `ctx` is plain data: `players`, `now`, `random()` (drawn from
  CSPRNG values the worker passes in; the call fails when they run out),
  `locale`, `sessionId`, `serverId`, `hostId`, `actorId`. The per-call
  Node child process (ADR-001) is gone.
- Legacy Node bundles (`index.js`) no longer install or load. The
  installer refuses an archive without `manifest.json` and `server.js` at
  its root before extracting it, and validates the manifest (action
  policies in the `GamePluginActionPolicy` shape, unknown policy keys
  refused, id and version equal to the catalog entry's, `ui/index.html`
  when `ui: true`). Digest pinning, the tar scan and `active.json` are
  unchanged.
- Marketplace plugins now get the official plugins' host features. The
  manifest's `actionPolicies` apply on the actions route (member and
  player actions, `actorFields`, `joinsRoster`, `audit`); the web app
  reads them from its own copy of the files and refuses a worker that
  reports different ones. `validateAction` runs before dispatch. A
  reducer that returns its input state is a refused action (no roster
  join, no audit), as for official plugins. `projectState(state,
  viewerId, ctx)` gives per-viewer hidden state on every read path — the
  activity GET, the SSE snapshot and state events, the actions response
  and the WebSocket gateway; a failed projection never falls back to the
  full state, and a plugin that is not loaded has its state withheld.
  Without `projectState` the state is public and the lobby says so.
- The gateway asks the web app for the projection of any plugin outside
  core's rules (`isCoreProjectedPlugin` in `@lobbyforge/core`) through
  the new internal `POST /api/internal/activity-projection`, signed with a
  key derived from the session secret and re-checking the viewer's
  access; if web cannot answer, the WebSocket event carries no state.
- The plugin worker sits on its own internal network, `plugin-sandbox`,
  which only `web` joins: no route to Postgres, Redis, LiveKit or the
  gateway. It makes no outbound calls (no plugin storage in sandbox v1;
  `PLUGIN_HOST_ORIGIN` is gone from its environment).
- Example: `examples/plugins/sandbox-buzzer` (manifest, `server.js` whose
  projection hides who buzzed first until the host reveals, and
  `pack.mjs`, which writes a reproducible tarball and its SHA-256). See
  `docs/PLUGIN_PUBLISHING.md`.
- Marketplace plugins can ship their own UI, and it runs sandboxed
  (ADR-007, client side). A bundle with `ui: true` and a `ui/` folder is
  shown in the lobby in `<iframe sandbox="allow-scripts">` (no
  `allow-same-origin`): an opaque origin that cannot read the app's
  cookies, storage or DOM. The files come from the new public route
  `GET /api/plugin-ui/{pluginId}/{version}/{...path}`, which serves only
  the ACTIVE version of an installed plugin that declares a UI, only an
  allowlist of extensions with fixed types, and refuses traversal. Its
  CSP is ADR-007's (`default-src 'none'`, `connect-src 'none'`,
  `frame-ancestors 'self'`, …) plus `sandbox allow-scripts`, so a plugin
  page stays sandboxed even if opened directly, and the frame has no
  network. Fetch Metadata keeps the files from being used anywhere else:
  the HTML loads only as an iframe document, and the app's own pages can
  never load a plugin script (the app's `script-src 'self'` would
  otherwise make one a gadget). The app CSP gains `frame-src 'self'`; the
  middleware and `next.config.mjs` leave that route to its own headers.
- The lobby talks to the frame with postMessage protocol v1: `init` and
  `state` (always the state already projected for the viewer) one way;
  `ready`, `action` and `resize` the other. Messages are accepted only
  from that iframe's window with origin `null`, checked with zod, capped
  at 64 KiB, actions at 10 per second, heights at 200–1200 px. Actions go
  through the same actions route and session as official panels. A plugin
  without `projectState` gets a "This app doesn't hide information"
  notice. `GET /api/plugin-ui/{pluginId}` (signed-in) tells the lobby
  which version to frame and whether the plugin projects its state.
- `@lobbyforge/plugin-sdk/frame`: a dependency-free frame client
  (`connect({ onInit, onState })` → `dispatch`, `resize`), with the
  handshake, auto-resize and host theme variables, plus the shared
  protocol types and limits. `scripts/vendor-frame-client.mjs` copies it
  into a plugin's `ui/` folder (no CDN inside the sandbox); a test keeps
  the copy in the sandbox-buzzer example current. See "Marketplace plugin
  UI (sandboxed iframe)" in `docs/PLUGIN_SDK.md`.

### Bots (Bot API v2 review fixes)
- A bot's channel access mode is stored (`bots.channel_access_mode`,
  `'all'` | `'selected'`, in migration 0044). A bot limited to chosen
  channels whose last channel is deleted or revoked now reaches NO
  channel; before, "no grant rows" read as the v1 rule and widened it to
  every open channel. The same holds for the gateway feed, the endpoint
  fan-out and the composer's command list. The single-channel revoke no
  longer refuses the last grant (`last_channel_access` is gone), and
  concurrent revokes cannot widen anything. Deleting a channel audits and
  notifies the bots that had it and drops the fan-out cache.
- When a channel gets a role restriction, bot grants on it made by someone
  who cannot manage channels are dropped (audited as `bot.channel_access`
  with `reason: 'channel_restricted'`).
- A bot can no longer undo a manager's command switch by deleting and
  re-registering the command: the managers' `enabled` and channel
  restriction are kept per bot and command name (`bot_command_overrides`).
- An ephemeral answer is not delivered to an invoker who left, was banned
  or lost the channel; the interaction fails (409 `interaction_failed`).
  The gateway also re-checks channel access before forwarding
  `interaction_create`.
- Interactions are pruned: answers are cleared once expired, rows deleted
  24 h after expiry. New indexes on `bot_interactions` (channel, command).
- Webhook display names refuse every control / invisible-formatting /
  separator character (bidi isolates U+2066–2069, U+061C, U+180E
  included). nginx keeps `/api/webhooks/` out of the access log (the token
  is in the path). Desktop notifications for bot and webhook messages say
  "(BOT)" / "(WEBHOOK)". `verifySignature` clamps `toleranceSeconds` to
  1–3600 (`Infinity` / `NaN` → 300).

### Voice and moderation
- Moderators can disconnect someone from voice, as in Discord:
  `POST /api/servers/{id}/channels/{channelId}/members/{userId}/voice/disconnect`.
  It needs **Mute Members** (there is no move-members permission) and the
  same rank rule as kick, ban and server mute: the moderator must outrank
  the target, nobody disconnects the owner, and disconnecting yourself is
  refused with 400 `self_action` (leaving is your own Disconnect button).
  It removes the member from that channel's LiveKit room, answers 404
  `not_in_voice` if they are not in it, and writes a `voice.disconnect`
  audit row. It does not block anyone and the token route does not refuse
  them, so they can rejoin straight away. Their lobby says "You were
  removed from the voice channel." In the voice roster, a "⋮" menu (or a
  right-click) on a member offers **Disconnect from voice**, with no
  confirmation step. It appears only for viewers with Mute Members and
  only on members they outrank. Refusals are shown in the viewer's
  language, using the `code` the route returns. The shared moderation
  gate (`lib/member-authorization.ts`) now returns a `code` with every
  refusal: `forbidden`, `insufficient_rank`, `target_is_owner`,
  `self_action`, `target_not_member`, …
- The audit log shows who the voice anti-cheat caught. Target users are
  shown by name ("Mallory (0a1b2c3d…)", full id in the tooltip). Names are
  looked up in one query that returns only the id and display name, never
  avatars. `voice.track_rejected`, `voice.block_enforced` and
  `voice.disconnect` rows get a one-line summary, for example "System
  removed **Mallory** from #Main Lounge: audio published as camera — voice
  blocked on this server for 10 minutes". The channel is named only if
  the viewer can see it. A new **Voice security** filter collects these
  three actions. The CSV export gains `target_name` and `summary` columns.
- When a voice-blocked member rejoins with an old token, the webhook
  writes a `voice.block_enforced` audit row (minutes left in
  `retryAfterSeconds`). A reconnect loop can't flood the log: Redis
  `SET NX EX` on `voice-block-enforced-audit:{server}:{user}` allows at
  most one row per user, per server, per minute. If Redis is unavailable
  the row is skipped and the removal still happens. A `voice.track_rejected`
  row caught by its media type (a "video" camera track carrying
  `audio/opus`) now records the `mimeType`.

## [Unreleased] - security follow-ups - 2026-10-03

### CI
- GitHub Actions supply chain (`docs/SECURITY_REVIEW_2026-10.md` §7.1):
  every action in every workflow is pinned to a full commit SHA with its
  version tag as a trailing comment (same majors as before), and
  Dependabot's `github-actions` updates now run weekly so the pins keep
  moving. `dtolnay/rust-toolchain` has no version tags: it is pinned to
  the `stable` branch tip with an explicit `toolchain: stable` and is
  bumped by hand. Every workflow defaults to `permissions: contents: read`,
  and write scopes sit only on the jobs that use them. CodeQL gets
  `security-events: write`. The desktop `publish` job gets
  `contents: write`. In `release.yml`, `docker-publish` and `promote` get
  `packages: write` and `github-release` gets `contents: write`. The
  `desktop` release job no longer inherits the old
  contents/packages/attestations/id-token write set. The job that reads
  `LF_RELEASE_SIGNING_KEY` now runs in a `release` environment, which
  GitHub creates without protection on the next release. The owner must
  add protection rules (see "Release signing key" in
  `docs/BETA_RELEASE.md`).

### Voice
- A member removed for a mislabelled track can no longer rejoin right
  away (§12, "still open"). LiveKit's `RemoveParticipant` does not revoke
  tokens, so the webhook now also blocks them from voice on that server
  (`lib/voice-block.ts`, Redis, server + user): 10 minutes, then 30, then
  120 for repeat offences within an hour of the previous block ending.
  The token route answers 403 `voice_blocked` with `retryAfter` (the
  lobby shows a translated message with the minutes left), failing
  closed in production if Redis cannot be read. On `participant_joined`
  the webhook removes a blocked identity again, which covers a token
  minted before the block. LiveKit tokens now live 10 minutes instead of
  an hour: LiveKit refreshes the token of a connected participant every
  5 minutes, so long calls are unaffected, and a removed member's old
  token dies sooner. TURN credentials keep their own 12 h lifetime.
  The block covers the whole server, so the webhook also removes the
  identity from every other live room of that server (a second
  connection in another voice channel no longer survives it), and the
  token route lower-cases the server and channel ids so the block key and
  the room name always agree.

### Hardening
- Bot tokens (`docs/SECURITY_REVIEW_2026-10.md` §7.7): issuing or
  rotating a custom bot's token hands over every permission the bot
  holds, so `POST /api/servers/{id}/bots/{botId}/token` now requires
  that the caller could grant each of them (the same no-escalation rule
  as bot create/PATCH; owners always can). Otherwise 403
  `ungrantable_permissions`. `PATCH /bots/{botId} {enabled:true}` on the
  built-in Moderation Bot now needs Manage Messages, like
  `PUT /bots/builtin/moderation`.
- App allow-lists (§7.7): the channel and role allow-lists saved in
  server settings → Apps are now enforced when an activity starts. A
  channel outside a non-empty channel list gets 403
  `app_channel_not_allowed` (owners included). A starter without one of
  the listed roles gets 403 `app_role_not_allowed` (owner and
  administrators bypass). The lobby shows both in English and Turkish.
- ws-gateway (§7.8): a failed subscribe no longer echoes the error
  message to the client (Drizzle errors carry the SQL). The client gets
  `internal_error` / "Subscription failed" and the detail goes to the
  log. Topic ids must be UUIDs before any database lookup; anything else
  is `unknown_topic`.
- Games use the platform CSPRNG (§7.8): Hushle card draws, team ids and
  the room split, Dice Bot rolls, and Quiz's question and option order
  (the built-in packs are public, so a predictable shuffle would place the
  correct answer) now use `crypto.getRandomValues`
  (the helper Vampire Village already used) instead of `Math.random`,
  whose state can be recovered from a few outputs. Injected RNGs in tests
  still work.
- Presence bandwidth (§7.8): a client-reported `bandwidthDeltaBytes` is
  clamped per report and counted against a per-user hourly budget in
  Redis (`lf:{env}:bw:user:{uid}:{hour}`), so a member can report at most
  14.4 GB per hour (240 MB per report). That limits how far one member
  can push the counters, but it can still trip the admin bandwidth
  alert: the threshold is operator-set, the budget allows about 346 GB a
  day, and every guest account has its own budget. Both numbers come
  from a generous 32 Mbit/s (4 MB/s) per participant: 60 s per report
  (a hidden tab's throttled timer; the heartbeat itself runs every 5 s)
  and 3600 s per hour.
- SSRF transport (§7.8): `fetchIpPinned` adds a response-header timeout
  (default: the idle timeout) and a total deadline (default: three times
  the idle timeout, so 45 s for plugin downloads and 30 s for directory
  verification), and it accepts a caller `AbortSignal`. Before, a server
  dripping one byte at a time could hold a request open indefinitely.
  `isBlockedNetworkIp` (web and registry) now blocks IPv4-compatible
  `::/96` addresses, and it judges 6to4 `2002::/16` addresses by the IPv4
  address embedded in them, so `2002:7f00:1::` counts as 127.0.0.1.
  Teredo (`2001::/32`, whose embedded IPv4 is obfuscated) and the
  local-use NAT64 prefix `64:ff9b:1::/48` are blocked outright.
- Plugin tarballs (§7.8): the pre-extraction scan joins the ustar
  `prefix` field (offset 345) with `name`, as tar does, and checks the
  joined path and each field. A `../` hidden in the prefix is rejected.
- `lfctl` (§7.8): `update apply` refuses to run when no release public
  key is found. Default paths (the release key, `.env.prod`, the compose
  file, deployment state, `backups/`, `infra/keys`) resolve from the
  checkout the script lives in, not the current directory. Run from
  another directory, the default key used to be missed, so the manifest
  counted as unsigned and its `imageDigest` could be deployed. Paths
  given on the command line still resolve from the cwd, and `LFCTL_ROOT`
  overrides the root. On POSIX, `backup create` makes a new backup
  directory 0700 and every dump and manifest 0600. An existing `--out`
  directory keeps its mode, with a warning when others can read it.
  Side effect: an apply now always verifies the manifest's signature, and
  a verified manifest must carry an `imageDigest`, so the old unsigned
  local-build path cannot run.

### Accounts and sessions
- Per-account sign-in limit (`docs/SECURITY_REVIEW_2026-10.md` §7.3):
  `POST /api/auth/login` and the desktop handoff start
  (`POST /api/auth/desktop-session`) share a failure counter keyed by the
  normalised email. After 10 failures in 15 minutes every further attempt
  gets the generic 429 with `Retry-After` until the window ends, whether
  or not the password is right; a successful sign-in clears it. Attempts
  are counted atomically before the password check, so parallel requests
  cannot add guesses. Unknown emails count and lock exactly like real
  ones (the dummy-hash path stays), so the limit tells nothing about which
  accounts exist. `POST /api/auth/password` allows 5 wrong current
  passwords per 15 minutes per user, across all sessions. Redis keys hold
  an HMAC of the email, never the address. If Redis is down in production
  the attempt is refused, like the per-IP limiter. Trade-off: anyone who
  knows an address can keep that account's sign-in locked in 15-minute
  stretches.
- Absolute session lifetime (§7.4): `POST /api/auth/guest` could refresh a
  session forever. Cookies now carry `auth_time` (when the session was
  first issued). A refresh keeps it and never signs an expiry past
  `auth_time` + `LOBBYFORGE_SESSION_MAX_AGE_DAYS` (default 30). An over-age
  session is signed out for API routes, pages and the ws-gateway, and the
  refresh route treats it like no session: a new guest identity, or the
  access-policy error. A signed-in user signs in again. Guest sessions are
  included, since a guest's identity is the cookie. The active-sessions
  list hides over-age sessions; "sign out other sessions" still revokes
  them. Compatibility: existing cookies keep working, their clock starts
  at their next refresh, and nobody is signed out by the upgrade.
- Google sign-in: the Google name goes through the registration
  display-name schema (2–64 characters, no control characters). A name
  that fails it gets a generated `Guest xxxx` name. The email address is
  never used as the public name, which happened when Google sent no name.
  The OAuth state and redirect cookies are read by exact name: the old
  regexes also matched cookies whose names only ended in `lf_oauth_state`
  or `lf_oauth_redirect`.
- Trusted proxy (§7.5): production without `LOBBYFORGE_TRUSTED_PROXY`
  still starts (no existing install is refused), but every client shares
  one rate-limit bucket. Doctor now reports this as a `trusted_proxy`
  warning with the fix, and the console warning is logged once per
  process instead of on every rate-limited request. The docs now say
  `cloudflare` is only safe when the origin accepts traffic from
  Cloudflare alone. With the bundled nginx, use `cf-real-ip.conf` with
  `x-forwarded-for`.

### Communities
- Join approval queue (replaces the AUTHZ-004 stopgap). A server whose
  access policy holds newcomers for approval no longer turns everyone
  away: an invite redeem or the lobby's "Ask to join" files a join request
  (migration 0043, `server_join_requests`, one pending request per user)
  and answers 202 `pending_approval`. The join page and the lobby say the
  request is waiting, or that it was declined. A moderator with Kick
  members or Manage server approves or rejects it in Community Settings →
  Members (`GET /api/servers/{id}/join-requests`,
  `POST …/join-requests/{requestId}`). Approval creates the membership
  like the auto-join (`@everyone`, stored timeout / server mute), greets
  the member and audits `member.join_approved`; rejection audits
  `member.join_rejected` and blocks a new request for 7 days. The
  requester can withdraw (`DELETE …/join-requests/mine`) and leave an
  optional note (≤ 500 characters). Bans win: a banned user cannot ask, a
  ban rejects the pending request, approval re-checks it. A request takes
  one use of the invite when it is filed (once per person and code), so a
  code cannot queue more people than its `maxUses`. At most 5 requests per
  server and user a day. Registration still refuses an approval policy.
  See `docs/INVITES.md` and `docs/ROLES.md`.
- Join approval queue, review follow-ups: opening `/lobby` no longer files
  a request (a GET — or a cross-site link — put any signed-in visitor,
  free guests included, in the queue). The "community unavailable" page
  shows an "Ask to join" form with an optional note
  (`POST /api/servers/{id}/join-requests/mine`, only for the community the
  lobby auto-join serves, same rules and limits as an invite) and a
  "Withdraw request" button while one is pending. Withdrawing and asking
  again through the same invite no longer burns a use each time: a user's
  later requests through a code they already used take none. A rejection
  written by a ban (flagged `rejected_by_ban`, a column in 0043) starts no
  7-day cooldown, so an unbanned user may ask again at once. Non-UUID
  server and request ids answer 404 instead of 500, `member.join_*` audit
  rows no longer carry the invite code, and a failed withdraw on the join
  page shows a translated message instead of a status code.
- Access-policy default: a server that never saved an access policy showed
  "Invite only" in its settings, while registration (which only applies a
  saved policy) allowed sign-up without an invite on an open instance.
  Saving the values shown then closed registration. The default now reads
  "Public self-register", which is what such a server has always enforced,
  so nothing changes for existing instances and saving the displayed
  values changes nothing. The column default follows (0043). Registration
  reads the saved policy through one helper
  (`serverPolicyRegistrationRefusal`).

### Plugins
These fix the marketplace (dynamic) plugin path, which is off unless
`LOBBYFORGE_DYNAMIC_PLUGINS_ENABLED=true` (`docs/EXTENDING.md` §3.5).
Unit and route tests cover them; no end-to-end test runs this path on a
real stack yet.
- A marketplace plugin keeps its state between actions. Its `migrateState`
  is an RPC to the plugin-worker, so it returns a Promise. The activity
  GET, SSE snapshot and actions routes, including the CAS retry loop,
  used that Promise without `await`. The reducer got `{}` on every
  action, and readers saw `{}`. Every call site now awaits it. Official
  plugins return plain values, so nothing changes for them.
- One install directory: `LOBBYFORGE_PLUGIN_INSTALL_DIR`, default
  `/app/plugins/installed`. The installer resolved `plugins/installed`
  from its working directory (`/app/apps/web` in the image). That is not
  on the `plugins-data` volume, so the worker never saw an install. The
  installer, the loader and the worker now share the setting; the worker
  still accepts the old name, `PLUGINS_DIR`. Compose sets the same value
  on web (read-write) and plugin-worker (read-only).
- Exact versions, and old versions removed. The installer records the
  active version and a SHA-256 digest of its files in
  `<pluginId>/active.json` on the volume. The web app sends that exact
  version and digest with every worker call. The worker re-hashes the
  folder and refuses a missing version (404) or a mismatch (409). Before,
  it ran the alphabetically last folder, so `1.9.0` won over `1.10.0`.
  An upgrade keeps the previous version active until the worker has
  loaded the new one; then the old folder is deleted. A refused bundle
  leaves the previous version active. The worker's `list` op is gone, and
  `describe` takes the exact version.
- Bundles must be self-contained. The installer refuses a bundle without
  `index.js` at its root; a nested one was accepted but could never load.
  The worker checks the exported plugin's shape. An import it cannot
  resolve now fails with a message to bundle every dependency
  (`react` and `@lobbyforge/plugin-sdk` included). The downloaded tarball
  is no longer left in the version folder. `docs/PLUGIN_PUBLISHING.md` no
  longer says to externalize them, that plugins run in-process, or that
  the catalog is shared between instances.
- The install API's "disabled" error no longer says plugins run
  in-process without isolation.
- Operators: nothing to do. No earlier install could reach the worker,
  so there is nothing to migrate. A plugin folder without `active.json`
  is ignored: install it again.

### Data
- Migration 0042 deletes the audit rows that leaked hidden game
  information before the PLUG-001 fix: Vampire Village player actions
  (night targets, shields, pack chat, votes …) and poll votes. Host
  actions and every other plugin's rows stay — old Watch Party rows
  include what were host actions before 2026-09-29.
- A ban that races a join approval now waits for the approval to commit
  and removes the membership it created (the ban updates the request
  before deleting the membership).

## [Unreleased] - security review fixes - 2026-10-03

The whole-codebase review in `docs/SECURITY_REVIEW_2026-10.md` found one
high, seven medium and nine low issues; all are fixed here.

### Operators — action needed
- **TURN was an open relay on every `install.sh` deployment (INFRA-001).**
  The installer writes `infra/turn/turnserver.conf` as 0600 root and the
  coturn image runs as `nobody`, so coturn never read its config and
  started with anonymous access and no peer restrictions. Pull this
  release and recreate the TURN container:
  `docker compose -f infra/docker/docker-compose.prod.yml --env-file .env.prod up -d turn`,
  then check it with `node scripts/turn-auth-probe.mjs <your-domain> 3478`
  (must answer 401). The container now starts as root, coturn drops to
  `nobody` after reading its config and certificate (`proc-user`), the
  watcher refuses to start without a readable config with REST auth, TCP
  relays are off and more special-purpose ranges are denied. TURN over
  TLS (5349) now works too — the key was unreadable before.
- Rendered configs (TURN secret) no longer enter locally built images
  (INFRA-002).
- Migrations 0039 (per-install directory id) and 0040 (moderation state
  that survives leaving) run on boot.

### Accounts and sessions
- A desktop sign-in code minted before a password change no longer
  completes after it; changing the password also drops outstanding codes
  (AUTH-001).
- Revoked sessions are signed out on server-rendered pages and admin
  screens too, not only on API calls; a request carrying two session
  cookies is refused (AUTH-002).

### Communities and moderation
- Deleting the last role that gates a channel is refused (409) instead of
  silently making the channel public (AUTHZ-001).
- Timeouts and server mutes survive leaving and rejoining; timed-out
  members cannot create invites; users who already left can be banned
  (AUTHZ-002).
- Only a message's author can change its text; Manage Messages keeps
  delete and pin (AUTHZ-003).
- Invites and auto-join respect "approval required" access policies
  (AUTHZ-004); the lobby says so instead of showing a data error. There
  is no approval queue yet, so those settings now turn away every new
  member (the settings page says so) — servers that had them on stop
  accepting invitees after this upgrade.
- "Profile visibility" is enforced: avatar, banner, bio and status are
  hidden from viewers it excludes (AUTHZ-005).
- A server mute also removes screen-share audio, a timeout every publish
  source; an empty source list now means no publishing at all (LiveKit
  treats an empty list as "everything") (AUTHZ-006).
- A server-muted member can no longer be heard by publishing their
  microphone as "camera" or "screen share". LiveKit only checks the
  source a track claims, not whether it is audio or video. The app now
  never plays a track whose kind does not match its source, and a new
  LiveKit webhook (`/api/livekit/webhook`, internal only) removes whoever
  publishes one and writes an audit entry (AUTHZ-006 follow-up). Operators
  of existing installs: to turn the webhook on, stop the stack and re-run
  `bash install.sh`, or re-render the configs with
  `LIVEKIT_WEBHOOK_API_KEY=<your LIVEKIT_API_KEY> bash scripts/render-configs.sh <domain> <turn-secret>`
  and recreate the `livekit` container. Without it, the app still refuses
  to play such tracks.

### Activities and plugins
- Gameplay actions are no longer written to the audit log, which named
  Vampire Village roles and anonymous poll voters; host actions are still
  audited when they change state. New optional `audit` flag on
  `GamePluginActionPolicy` (PLUG-001).
- Channel visibility checks require membership, so a kicked host cannot
  end their activity (PLUG-002); action types named after
  `Object.prototype` members no longer bypass the host check.

### Images
- Member lists, DMs and block lists no longer inline avatar/banner data
  URLs; images are served from `/api/users/<id>/avatar|banner` with the
  owner's profile visibility applied. The lobby member list is capped at
  500 (FILE-001).

### Directory
- Each install has its own random directory id; `.well-known` and the
  admin config work again (they read a row that never existed) (HUB-001).
- Re-registering a listed entry needs the stored key and domain and sends
  changed listings back to review (HUB-002); stale entries are no longer
  linked from detail or leave-site pages, and admins see every entry with
  its status (HUB-003).
- Verification failures return a generic message, single-label hosts are
  rejected and the directory write routes exist only on the official hub
  (FILE-002).

## [Unreleased] - official hub, plugin UI kit, finished games and bots - 2026-09-29

### Official hub
- New landing to the "Calm Future" design (live-room hero, activities
  showcase, community preview, install steps, a repo card with the live
  GitHub star count), official sign up / sign in and a hub home at
  `/home`. ADR-006 is amended: the hub account is optional and never
  signs anyone into another instance.
- Directory, instance details, marketplace, create-a-community and
  connect use the hub chrome; `/discover/go` is reachable again (it
  was served at `/go` and linked as a 404).

### Plugins
- `@lobbyforge/plugin-sdk/ui`: a theme-aware UI kit every official
  panel is built on.
- Quiz: built-in question packs (English, Turkish) injected on the
  server, a deadline per question, speed and streak scoring, reveal,
  leaderboard and podium.
- Vampire Village: the full game — seven roles, night actions, day
  votes, pack chat, deadlines, spectators — with per-viewer secrets in
  the core projection.
- Watch Party: YouTube playback in sync for the room; CSP `frame-src`
  allows exactly `https://www.youtube-nocookie.com`.
- Hushle: a new panel; every player now explains in turn, the floater
  alternates between teams and the timer runs per turn (state v3,
  migrated on read).
- Poll and Dice Bot: new panels.
- Hosting fixes: anyone who acts joins the activity's roster (before,
  only its creator did), players are named by display name, the voice
  channel's people are offered to panels, and the lobby finds a game
  someone else started.

### Bots
- Bot tokens (shown once, hashed), permissions, Bot API v1, built-in
  Welcome and Moderation bots, Community settings → Bots, BOT badges and
  profiles, `createBotClient` in `@lobbyforge/bot-sdk`. See `docs/BOTS.md`.
- Migration 0037 (bots runtime, additive) and 0038: members the lobby
  auto-join had created without any role get `@everyone`.

### Working with agents
- `.claude/agents/` (plugin, frontend, bot, e2e, translator, reviewer)
  and `docs/AGENT_TEAM.md`.

## [Unreleased] - beta readiness remediation - 2026-09-19

Fixes for everything found by the 2026-09-19 beta-readiness review
(`docs/BETA_READINESS_REVIEW.md`). Most items were first reproduced live
against the compose stack, and each fix ships with a regression test.

### Security: authorization and moderation

- **S1: MANAGE_ROLES could escalate to ADMINISTRATOR.** Role create,
  edit and assign now go through `lib/role-grant-policy.ts`. A non-owner
  may only add permissions they hold themselves and may never add
  `administrator`. Removing permissions is always allowed. Live repro:
  a moderator gave `@everyone` administrator, and every member got
  audit-log and ban access.
- **S2: bans did not remove access.** `banUser` now deletes the
  membership in the same transaction. `isServerMember`,
  `getUserPermissions`, `ensureServerMembership` and `redeemInvite` all
  reject active bans. Ban, kick, timeout and role assignment evict or
  re-sync the user in LiveKit. Live repro: a banned user still posted
  messages and minted voice tokens.
- **S3: `/lobby` leaked role-gated channels.** The page applies the
  REST rules (`lib/lobby-channel-access.ts`): only visible channels are
  listed, and messages load only through `authorizeChannelMessageAccess`.
- **S4: voice moderation now reaches LiveKit.**
  - Server mute is persisted (`memberships.voice_muted`, migration 0036)
    and the token route withholds the microphone grant while it is set.
  - `lib/voice-moderation.ts` updates `canPublishSources` live and mutes
    the MICROPHONE track. The old code matched `source === 1`, which is
    the CAMERA.
  - Users who lose access are removed from the room: kick, ban, timeout,
    role edits and channel visibility changes.
  - `enable_remote_unmute: false`: a moderator "unmute" can no longer
    switch someone's microphone on.
  - Moderators with MUTE_MEMBERS get a mute control in the voice roster.
- **S7: password change left sessions alive.** Login, desktop handoff,
  Google OAuth and setup now record their sessions, so
  `revokeOtherSessions` sees them. Unrecordable sessions are refused in
  production. Also fixed: the Google callback deleted the cookie it had
  just set, so Google sign-in never logged anyone in.
- **S8:** the voice display name is resolved server-side (nickname,
  then profile name). The client-supplied `displayName` is ignored.
- **Invites:** members list and revoke only their own invites; others'
  require MANAGE_SERVER.
- **Google OAuth** account creation respects the instance registration
  mode.
- **Timeouts** now also block message edits and pins.
- **S5: realtime presence leaked private data.** The gateway received
  raw status, activity, server name and voice channel, including for
  users who hid them or blocked the viewer. Presence events now carry
  only `{type:'presence-update'}`; the gateway sanitizes them even for
  old publishers, and clients re-fetch REST. REST and the
  server-rendered lobby share one projection (`lib/presence-view.ts`):
  blocks, per-user privacy, role-gated channels hidden (F8), and a
  hidden status no longer leaks through `lastSeen`.
- **S6: any member could exhaust Redis connections through the
  gateway.** The gateway now uses one shared subscriber connection with
  per-topic refcounted SUBSCRIBE. Chat topics must name an existing
  channel of that server. Subscriptions are capped at 64 per connection
  and 256 per user. Also fixed:
  - an IP-slot leak on aborted upgrades;
  - a channel-policy invalidation miss for activity topics.
- **S10:** directory change-domain requires the stored key and resets
  `isVerified`/`isListed`, so the new domain needs admin re-review.
- **S11: anonymous polls.** The projection replaces `ballotBox` with
  `ballotCount` plus the viewer's own `hasVoted`.
- **Hushle:** `usedCardIds` is projected to a count, so guessers can no
  longer read the current card id.
- **Ended activities are read-only.** The actions route checks the
  session ROW status, and the CAS `WHERE` refuses terminal sessions, so
  a concurrent end wins. `validateAction` now sees the server-injected
  actor fields.
- **Quiz:** one locked answer per player per question. Scores are
  revealed only on the host's `reveal`.
- **S9:** `.dockerignore` excludes `infra/keys` (the release signing
  key was being copied into locally built images), `infra/certbot`,
  `backups`, `*.pem`/`*.key`/`*.dump` (except the release public key)
  and the multi-GB `src-tauri/target`. The CI docker job asserts that
  the image holds no such files.

### Voice

- **PTT works.** The keybind listener no longer re-binds when the mic
  state changes; that re-bind had released the key while it was still
  held. A single-flight loop now makes the mic follow the key, and
  `blur` or a hidden tab counts as a release.
- **Desktop PTT and shortcuts work.** The Tauri shell posted
  `{source: window}`, which is not structured-cloneable, so every
  message threw `DataCloneError`. Payloads are now JSON-serialized, with
  Rust tests, and the web app handles `lobbyforge:shortcut`.
- **Desktop could not connect at all.** The unbundled connect screen
  uses `window.__TAURI__`, but `withGlobalTauri` was off, so `invoke`
  was undefined and "Connect" failed with "invoke is not a function" in
  every release build. It is now enabled.
  - Verified on real Windows: the connect screen works, and global
    Ctrl+Space / Ctrl+Shift+M / Ctrl+Shift+D reach the page.
  - Instance pages still get "not allowed by ACL" for every command.
  - A regression test pins the config.
- **Deafen** covers publications that appear later (new joiners,
  unmute, reconnect) and mutes your own mic. Undeafen restores it.
- **Microphone failure no longer aborts the join.** The app retries the
  default device, then continues listen-only with a readable message.
- **Mic indicators** follow the real publication. A moderator mute shows
  as "Muted by a moderator", and your own tile shows your mute.
- **Activities page (`/room`) plays remote audio** (it never attached
  any).
- **Other voice fixes:**
  - An autoplay block shows an "enable audio" button.
  - Disconnect reasons are shown (another tab, removed, room closed).
  - Stale `<audio>` elements are removed.
  - Per-user volume is re-applied on rejoin.
  - The output device preference applies to calls.
  - Settings request mic and camera separately, so PCs without a webcam
    work.
  - TURN credentials last 12h.
- **Release image no longer bakes `localhost` media URLs.** The
  Dockerfile has no `NEXT_PUBLIC_*` defaults. The token route returns the
  LiveKit URL at request time, and the browser falls back to the
  same-origin `/livekit` and `/ws` proxies. Previously
  `lfctl update apply` broke voice and realtime.

- **TURN relay never started.** coturn 4.18 accepts only a single IP or
  an IP-IP range in `denied-peer-ip`. The IPv6 CIDR lines (`::/128`,
  `fc00::/7`, …) made it abort at boot, so TURN crash-looped on every
  install and users on UDP-blocking networks had no voice.
  - The deny rules are now written as ranges with the same coverage,
    plus a range for IPv4-mapped IPv6.
  - The deprecated `no-tlsv1*` and `no-cli` switches are dropped.
  - CI boots the pinned coturn image with the rendered config.
  - Caught by the local rc.5 → rc.6 update drill.

### Dev / CI stack

- `docker-compose.dev.yml`:
  - `ws-gateway` gets `working_dir: /app`. It was crash-looping with
    `MODULE_NOT_FOUND`.
  - `ws-gateway` gets `LF_DB_URL`. Every subscription was being rejected
    with `unknown_topic`.
  - `ws-gateway` gets a healthcheck, so `--wait` catches a crash loop.
  - `web` gets `LIVEKIT_URL`. Server mute returned 500.
  - LiveKit is pinned to the production version (was `:latest`).
- `docker-compose.e2e-ports.yml` passes the gateway origin and LiveKit
  URL, and documents the build-time URL args. `.env.example` documents
  `LF_DB_URL`.
- The realtime client sends one wire subscription per topic. It used to
  send each topic twice after every connect.
- CI changes:
  - The real-UI voice + realtime E2E runs in CI
    (`voice-ui-audio.spec.ts`). It asserts WebRTC `inbound-rtp` bytes and
    audio energy.
  - `voice-two-clients.spec.ts` no longer races its own assertions.
  - `integration.test.ts` and `bans.integration.test.ts` run against
    real Postgres; the integration test is now self-bootstrapping.

## [Unreleased] - plugin action hardening (31st audit) - 2026-09-17

### Security / correctness

- **SDK `validateAction` contract**: the activity API only validates
  `{ type }` at its boundary — plugin-specific fields arrive as raw
  JSON from any client. Plugins may now declare `validateAction`; the
  host calls it BEFORE dispatch (and before the idempotency claim) and
  rejects malformed payloads with a clean 400 instead of letting the
  reducer crash into a 500. Reducers stay defensive anyway (belt and
  braces).
- **Poll — anonymous by construction (product decision)**: state now
  stores per-option VOTE COUNTS + a ballot box (who has voted) and
  never WHO voted for WHAT — the canonical projector passes plugin
  state to every viewer, so per-option voter lists would have
  published every ballot. Trade-off documented: no server-side
  "my vote" indicator.
- **Poll — honest voter contract**: any channel member may vote
  (`role: member`); the host voice context is still a stub, so
  voice-presence enforcement is not server-side possible today —
  `requiresVoiceRoom` set to false to state the real contract.
- **Poll/Dice malformed-payload coverage**: null questions, non-array
  options, non-integer dice sides (6.5 could roll a 7; strings/objects
  could inject NaN into state) — all rejected at the boundary AND
  ignored by the reducers (tests: Poll 13, Dice 9).

### Reliability

- **React type unification (root pnpm overrides)**: packages/ui and
  hushle carried their own react/@types/react (hushle on the 18.x
  line while the app is on 19.x) — the rc.5 first-attempt Docker build
  failed on React type-identity duplication. All react/react-dom/
  @types/* now resolve to one workspace-wide version; hushle's
  declared devDeps aligned.
- **Windows checksum UX**: the PowerShell example now compares
  expected (.sha256) vs actual and throws on mismatch — same
  fail/pass semantics as shasum -c.


## [Unreleased] - restore auto-discovery + checksum UX + Poll & Dice Bot plugins - 2026-09-17

### Fixed (30th audit)

- **P1 — backup restore on a real production install**: restore now runs
  ensurePgContainer(targetUrl) first — the CI drill masked the missing
  discovery by exporting LFCTL_PG_CONTAINER explicitly. New
  production-shape regression spec (container env unset; fake docker
  proves psql/pg_restore run via docker exec; a tampered dump is
  refused before any tool runs).
- **install.sh closing hint** no longer says "git pull && bash
  install.sh" (it told operators to move a production checkout to
  live main, bypassing the signed-release model) — only the lfctl
  update commands are shown.
- **Per-artifact checksums**: every release asset now ships a
  matching `<file>.sha256` (standard hash+filename format) so users
  verify exactly the file they downloaded; download-page commands use
  real asset names and `shasum -a 256` on macOS.

### Added

- **Poll plugin** (`@lobbyforge/poll`): live one-vote-per-player polls
  for voice rooms — state-enforced single ballot (kept across
  close/reopen), host lifecycle, decisive-leader tally helpers;
  9 reducer/policy tests; registered as a bundled plugin.
- **Dice Bot plugin** (`@lobbyforge/dice-bot`): bot-style utility —
  server-side rolls (client never supplies the outcome), clamped dice
  sides, per-player stats (rolls/sum/best), capped newest-first
  history, host toggle + stats reset; 6 tests; bundled.

## [Unreleased] - hub design sprint: landing, connect, download, discover, register - 2026-09-17

### Added

- **Landing redesign** (the hub thesis surface): left-aligned hero with
  the approved line "Your community. Your server. Your rules.",
  Bricolage Grotesque display face (marketing shell only), honest
  self-host section (real requirements + the four real steps +
  Cloudflare guide link). Signature element: the **Voice Strip** — a
  voice room reduced to its signal, one speaker ON AIR in the new
  `ember` token; pure CSS, reduced-motion safe.
- **Product Connect page** (the M9 demo moved to /connect/demo for the
  dev e2e specs): instance-URL entry with normalization + inline
  validation, opens the community's OWN /login (ADR-006), recent
  communities in localStorage, desktop deep-link note.
- **Download page** (marketing shell): platform cards → GitHub
  Releases, honest unsigned-build notice (ADR-005) with SHA256SUMS
  verification guidance.
- **Instance detail pages** (`/discover/[instanceId]`): identity +
  verified state, description, live stats (online / public rooms /
  doctor score / heartbeat freshness), region / languages / tags /
  version. Browser exits go through the /discover/go external-redirect
  interceptor; native desktop launches use the protected lobbyforge://
  flow.
- **/register route**: invite-preserving deep link into the auth
  shell's Create-account tab (`/login?mode=register` — all guards run
  on /login; LoginForm takes an initialMode).

### Changed

- **Discover quick filters**: Verified only / Online now / language
  chips (client-side; the server keeps search + region), cards link to
  instance detail, empty state offers connect-by-address.
- **Hub nav** per ADR-006: Sign In removed → Communities / Connect /
  Download / Self-host; footer links wired to real targets.

## [Unreleased] - lobbyforge.org + Cloudflare deployment support - 2026-09-17

### Added

- **Bring-your-own certificate path in install.sh**: skipping certbot now
  offers installing an existing PEM pair (e.g. a 15-year Cloudflare
  Origin CA certificate) instead of aborting. The installer validates
  that both files parse AND that the certificate matches the key
  (public-key comparison) before installing them at the exact path nginx
  expects; the fail-closed no-certificate abort stays. Two new
  installer-flow scenarios cover the happy path and the mismatch
  rejection (9/9).
- **docs/DEPLOY_CLOUDFLARE.md** — the full Cloudflare deployment guide:
  proxy matrix (HTTP/WS proxied; TURN hostname must be DNS-only; ICE
  media is direct-to-server-IP), Full (strict) + Origin CA (15y .pem)
  provisioning steps, certbot-sidecar noise note, 15-year replacement
  via the cert-watcher, troubleshooting table — and an explicit
  "not everyone deploys like this": Let's Encrypt stays the documented
  default.
- **infra/nginx/cf-real-ip.conf.example** — opt-in real-visitor-IP
  restore behind Cloudflare (set_real_ip_from CF ranges +
  CF-Connecting-IP); without it every visitor shares an edge IP and
  IP-based rate limits aggregate. Copied into conf.d/ to activate.

### Changed

- **Domain switched to lobbyforge.org** across the product plan and
  ADR-006 (official hub lobbyforge.org, instance community.lobbyforge.org,
  docs.lobbyforge.org).

## [Unreleased] - product direction: Hub/Discover/Official Instance plan - 2026-09-17

### Added

- **ADR-006 — no central authentication**: the Official Hub has no
  login/register; identity stays instance-local; self-hosting never
  depends on a central account. A future LobbyForge ID would be an
  OPTIONAL identity provider (hub conveniences + instance OAuth),
  never a self-host requirement.
- **docs/PRODUCT_HUB_PLAN.md** — the accepted direction for the first
  post-RC product sprint: real landing at `/`, product `/discover` +
  instance detail pages, `/connect` (replacing the phase-1 M9 demo),
  `/download`, the official dogfood instance at
  community.lobbyforge.com, the shared instance auth shell (with
  registration-policy states), and an honest current-state → target
  gap list. Trending/ranking, hub login and LobbyForge ID are
  explicitly out of beta scope.

## [Unreleased] - strict allowlist for the zero-side-effect test (28th audit) - 2026-09-17

- The drift-abort scenario now proves all three invariants directly:
  no filesystem mutation (no backups/ dir, no deployment-state.json),
  no .env.prod mutation, and every docker invocation on a read-only
  allowlist (compose ps / inspect / image inspect) — a stray
  tag/build/rm would fail the suite, not slip past a pull/run/up
  blacklist.

## [Unreleased] - drift side-effect coverage + doc nits (27th audit) - 2026-09-17

- New regression scenario asserting the zero-side-effect drift abort
  DIRECTLY on the auto-backup path (no --backup-manifest): no backups/
  directory is ever created and the only docker invocations are the
  read-only preflight ones (recovery spec 8/8).
- BETA_RELEASE platform wording matches the workflow: the amd64 pin is
  explicit in code, not a "GitHub runner default".

## [Unreleased] - fleet-wide drift preflight (26th audit) - 2026-09-17

### Changed

- **Drift preflight covers the whole app fleet and runs BEFORE the
  backup**: the running-image ground-truth check now resolves web +
  ws-gateway + plugin-worker (migrate is one-shot, not a running
  container) and requires all three to share one image ID — a
  hand-edited ws-gateway no longer slips past a web-only check. The
  preflight is read-only and moved ahead of the auto-backup, so a drift
  abort now has literally zero side effects (previously a fresh backup
  file could be created first). The rollback-anchor tag creation stays
  after the --yes gate. New mixed-fleet regression scenario (spec 7/7).
- BETA_RELEASE audit line no longer carries a hardcoded round count.

## [Unreleased] - drift invariant + explicit platform (25th audit) - 2026-09-17

### Changed

- **Deployed-image drift check**: when `.env.prod` pins an immutable
  digest, `lfctl update apply` now resolves that ref locally
  (`docker image inspect`) and compares it with the RUNNING container's
  image ID — a mismatch (someone changed the deployment outside lfctl)
  aborts the update before touching anything. Registry manifest digests
  and container image IDs are different hashes, so the configured ref is
  resolved rather than string-compared. Two new regression scenarios
  cover the match and the drift paths (spec now 6/6).
- **Release build platform is explicit**: `platforms: linux/amd64` on
  the candidate build instead of relying on the runner default — the
  amd64-only beta policy is now code, not ambient behavior.

### Docs

- BETA_RELEASE audit-rounds line made version-independent ("findings
  remediated; hardening continues via the RC drill, not more static
  review") — it no longer goes stale every round.
- Scanner pin policy clarified: digest-pinning the Trivy image fixes the
  scanner binary, not its version forever — deliberate version bumps
  remain maintenance (DB refreshes every run regardless).

## [Unreleased] - release hardening follow-ups (24th audit) - 2026-09-17

### Changed

- **Trivy scanner digest-pinned** (all three scan invocations — security
  workflow x2 + release exact-digest scan): the scanner container mounts
  the docker socket, so its own bytes must be immutable too.
- **Rollback anchor pins the RUNNING container's image ID** (`compose
  ps -q web` → `inspect .Image` → `tag sha256:… rollback-<ts>`) instead
  of the mutable `:latest` ref — if the tag drifted since the container
  started, the old code would have pinned the WRONG bytes. No running
  container / unresolvable image aborts the update before touching
  anything (new 4th regression scenario).
- **Rolling MAJOR.MINOR tag only for final releases**: prereleases no
  longer mint `0.2.0-rc` aliases (`0.2.0-rc.1` promotes only itself +
  the sha tag).

### Docs

- BETA_RELEASE: linux/amd64-only release image stated as a beta
  constraint (ARM64 = post-beta evaluation), and the public-package
  candidate visibility trade-off recorded as an explicit pre-stable
  decision (separate private candidates package if the strict bar is
  wanted).

## [Unreleased] - recovery timing + staged image publishing (23rd audit) - 2026-09-17

### Fixed

- **P1 — recovery flag was set AFTER `compose up --wait`**: `up` is not
  atomic — it can create the NEW containers and still exit non-zero
  when a healthcheck fails, which left `servicesRecreated` false and
  the recovery branch unreachable exactly when it was needed. The flag
  (`servicesMayHaveChanged`) is now set BEFORE the command.
- **P1 — image was public under release tags BEFORE its scan**: the
  build pushed semver tags + made the package public, and only then did
  a separate job scan the digest. Publishing is now staged: build+push
  to a `candidate-<sha>` ref → Trivy scan of the exact digest → only
  then does `promote` attach the public semver tags (metadata-only
  `buildx imagetools create`, no rebuild) and ensure package
  visibility. A scan-failing build never exists under a release tag.

### Changed

- **Rollback anchor is fail-closed**: if the currently-running mutable
  image cannot be pinned to `lobbyforge-web:rollback-<ts>`, the first
  digest update ABORTS instead of proceeding with a rollback promise
  it cannot keep.
- **lfctl docker invocation overridable** (`LFCTL_DOCKER`, may carry a
  launcher prefix) — operators can wrap docker, and the new recovery
  regression spec drives the REAL CLI through a fake docker.

### Added

- **CLI rollout-failure regression spec**
  (`apps/web/lib/__tests__/lfctl-update-recovery.test.ts`): runs
  `lfctl update apply` end-to-end against a fake docker that scripts
  `up` exit codes — asserts the partial-failure sequence (new
  containers created, `up` fails → old containers restored +
  health-checked; recovery fails → previous pointer kept), the
  byte-exact rollback anchor in `.env.prod`, and deployment-state
  contents. This is the test that would have caught the flag-timing
  bug.
- **Expand/contract migration release policy** documented in
  docs/BETA_RELEASE.md — app rollback restores the previous image
  against a forward-only schema, so releases must keep the old image
  compatible with the expanded schema (destructive cleanup only in a
  later release).

## [Unreleased] - update recovery + GHCR access hardening (22nd audit) - 2026-09-16

### Fixed

- **P1 — failed rollout left broken NEW containers running**: the catch
  handler restored `.env.prod`'s image ref but never recreated the old
  containers if the new ones had started, and wrote `previous: null`
  into deployment state — making `lfctl update rollback` refuse exactly
  when recovery was needed. The runner now tracks whether services were
  recreated; on failure it restores the ref AND brings the stack back
  up on it + health-checks. If auto-recovery also fails, the state
  keeps a WORKING previous pointer and prints the rollback command.
- **P1 — GHCR access on first release**: GHCR packages default to
  PRIVATE. The release scan job now carries `packages: read`, logs in
  and PULLS the exact digest before Trivy (works regardless of
  visibility); a new post-publish step verifies/sets the package PUBLIC
  and fails the release loudly otherwise (anonymous digest pull is how
  every self-host updater fetches releases).
- **Version resolution edge**: an untagged checkout install recorded
  `LOBBYFORGE_VERSION=source`, which crashes the updater's semver
  parser. install.sh now falls back to the root package.json version
  (then `0.0.0-unknown`); lfctl refuses to guess an unknown current
  version entirely (clear error instead of silently assuming 0.2.0).
- **install.sh update bypass removed**: the "Update to the code in this
  checkout" menu action ran `compose up -d --build`, bypassing signed
  manifests, backups, digest pinning and rollback state — and could
  silently flip a digest-pinned deployment back to a mutable local
  build. Option 1 now only prints the lfctl update commands; the
  installer is fresh-install/config-repair only. Re-runs also PRESERVE
  existing LOBBYFORGE_VERSION/IMAGE instead of resetting the update
  model.

### Changed

- **First rollback is byte-exact**: before the first digest update, the
  running local image is pinned to `lobbyforge-web:rollback-<ts>` and
  THAT is the rollback target — later rebuilds of `latest` can no
  longer silently change what rollback restores.
- **Verified manifests must pin deployable bytes**: a manifest whose
  signature verifies against a pinned key but has no `imageDigest` is
  refused by `lfctl update apply`, and the generator refuses to SIGN a
  manifest without `--git-sha` + `--image-digest` (signature over a
  bare version number is trust theater). The warned local-build path
  remains only for unsigned manifests.

### Added

- **v0.2.0-rc.1 release drill checklist** in docs/BETA_RELEASE.md:
  anonymous digest pull from a clean VPS, signed-manifest verification,
  old→RC update, forced-health-failure rollback, backup restore,
  three-platform desktop artifacts; notes that GitHub `releases/latest`
  excludes prereleases (RC tests must pass `--manifest` explicitly).

## [Unreleased] - signed-digest update chain (21st audit) - 2026-09-16

### Fixed

- **BLOCKER — updater never deployed the target release** (21st-audit):
  `lfctl update apply` only rebuilt the CURRENT checkout (`compose
  build --pull`) — a signed manifest saying "0.2.1" changed nothing
  about the deployed bytes. Updates are now DIGEST-driven: the release
  workflow exports the pushed image digest, the signed manifest pins
  `gitSha` + `imageDigest`, and apply sets `LOBBYFORGE_IMAGE` in
  .env.prod to that digest, pulls it, runs the new migrator, recreates
  services and health-checks. Legacy unsigned manifests fall back to
  the local-build path with an explicit warning that the signature
  does not vouch for those bytes.
- **BLOCKER — release gate had no `checks: read`**: with workflow-level
  permissions defined, unspecified scopes are `none`; the verify job
  now carries explicit `contents: read` + `checks: read`.
- **BLOCKER — documented check → plan → apply chain didn't run**:
  `--manifest` isn't carried between invocations. check/plan/apply now
  default to the official latest-release manifest URL, apply
  auto-creates a FRESH backup (pg tools resolved into the compose
  postgres when DATABASE_URL is compose-internal) instead of pointing
  at the example manifest, and `update check` fails closed (exit 2) on
  unsigned/tampered manifests when a key is pinned.
- **Desktop release job on Windows**: collection step is Bash but the
  windows-latest default shell is pwsh — the desktop job now forces
  `defaults.run.shell: bash`.
- **Semver pre-release ordering**: the parser captured but then
  dropped pre-release identifiers (0.2.0-rc.1 == 0.2.0-rc.2 ==
  0.2.0). Full semver §11 precedence implemented (numeric <
  alphanumeric, fewer identifiers = lower, release > pre-release) —
  RC pipelines can now be tested with lfctl.
- **Checksum structure vs release assets**: per-platform SHA256SUMS
  collided as identically-named assets and aggregate paths didn't
  match flat asset names. Installers now stage FLAT with
  `desktop-<platform>-` prefixes and a single aggregate SHA256SUMS
  whose lines match the exact downloadable filenames.

### Changed

- **Update runner semantics are honest**: required steps without an
  executor ABORT the update (previously "skipped" then "Update
  completed successfully"). Doctor runs a real preflight health probe,
  backup is actually created (auto) and verified, migrations run via
  `docker compose run --rm migrate` with the NEW image before services
  recreate, and the migration dry-run is labeled informational
  (drizzle journals are forward-only).
- **Rollback is implemented** (was "locked"): apply records the
  previous image+version to `infra/update/deployment-state.json` (and
  restores .env.prod's image ref if a step fails); `lfctl update
  rollback` redeploys the recorded previous state and health-checks.
  App-level only — DB migrations are forward-only, documented inline.
- **Deployed version state is persisted**: install.sh writes
  `LOBBYFORGE_VERSION` (+ `LOBBYFORGE_IMAGE`) to .env.prod from the
  cloned tag; apply updates both and writes deployment-state.json;
  lfctl resolves the current version from flags > env > .env.prod >
  state file — no hardcoded assumption.
- **Release signing is fail-closed**: release-manifest.mjs refuses to
  write an unsigned manifest when the key is missing (secret deleted /
  wrong environment breaks the build); `--allow-unsigned` is the
  explicit local-testing escape hatch.
- **Public key pinned by default**: lfctl verifies signatures against
  the committed official key (`infra/update/release-public.pem`) when
  no --public-key/env override is given — unsigned/tampered manifests
  fail closed out of the box; forks override explicitly.
- **Actions pinned to full commit SHAs** in release.yml (write-scope
  workflow) per GitHub's supply-chain guidance.
- **Branch protection tightened to 19 required checks** (added DB
  migration integrity, Desktop shell cargo check, scheduled dependency
  audit) matching the release gate; new `release-tags-immutable`
  ruleset forbids force-push/deletion of `v*` tags.

### Added

- **Trivy scan of the exact released digest** (new release workflow
  `scan` job): CRITICAL+HIGH gate on `ghcr.io/...@sha256:<digest>`
  before the GitHub Release is created.
- **Desktop version follows the unified release tag**: the workflow
  injects the tag version into tauri.conf.json + package.json before
  building, so installer filenames/metadata match the release.

## [Unreleased] - release + update chain hardening (20th audit) - 2026-09-16

### Fixed

- **`lfctl update apply` variable bugs (20th-audit P0)**: the safety
  gates referenced a `check` variable that only exists inside
  `buildPlan()` — the apply path uses the flattened `plan`. Signature
  gate now matches the verifier's actual shape
  (`signature.required && !signature.verified`) instead of the
  non-existent `signature.valid`, and `--force-major` is recognized by
  the argument parser.
- **Release workflow changelog step**: stray `)` at the end of the
  heredoc block broke the shell step with a syntax error on every
  release.
- **GHCR tag mismatch in release text**: the body advertised
  `ghcr.io/…:v1.2.3` but metadata-action publishes the v-stripped
  semver tag on the lowercased repo name. The body now derives the
  image ref from `GITHUB_REPOSITORY` + `GITHUB_REF_NAME` the same way
  metadata-action does.
- **Install instructions**: `curl | bash` against install.sh never
  worked — the installer needs the repo files (compose stack, nginx/
  livekit templates, lfctl) next to it. Release body and beta docs now
  use `git clone --branch <tag> --depth 1`.

### Changed

- **Pre-release verification is a real gate** (was: printed check runs
  and moved on): `scripts/release-gate.mjs` polls the check-runs API
  for the tagged SHA until all 19 required CI+security contexts are
  completed, then requires success on every run (a stale failed branch
  run cannot be outvoted by the tag run). Fails closed on failed /
  missing / timeout. `ci.yml` and `security.yml` now also trigger on
  `v*` tags so the gate has check runs to wait for.
- **Desktop release job is honest**: job-level `continue-on-error`
  (failures are visible but cannot block the server release — ADR-005),
  a real `tsc + tauri build` (the old step built nothing: it esbuilt a
  non-existent `src/shell.ts` behind `|| true`), and bundles are
  collected from `target/release/bundle/` (dmg/msi/nsis/deb/AppImage)
  instead of the raw binary path.
- **Container-mode backup streams to disk** (was: buffered the entire
  `-Fc` dump in RAM with `maxBuffer` up to 1 GiB): `docker exec` stdout
  is piped straight into the output file; a failed or timed-out dump
  deletes the partial file so it can never masquerade as restorable.

### Added

- **Signed release manifests for `lfctl update`**
  (`scripts/release-manifest.mjs`): every release publishes a
  `release-manifest.json` asset — Ed25519-signed (signature embedded,
  canonicalization byte-identical to lfctl's verifier, round-trip
  checked before publishing) when the `LF_RELEASE_SIGNING_KEY` secret
  is configured. The release body includes the `lfctl update check`
  command pointing at the asset. The signing key pair is provisioned:
  public half committed at `infra/update/release-public.pem` (keyId
  `34c793ff090fc436`) for client pinning, private half held as the
  GitHub secret (local backup at `infra/keys/`, git-ignored).

## [Unreleased] - release engineering + beta readiness - 2026-09-16

### Added

- **Release workflow** (.github/workflows/release.yml): tag-push →
  pre-release verification → Docker image publish (ghcr.io with SBOM
  + provenance attestations) → desktop bundle builds (Linux/Windows/
  macOS with SHA256 checksums) → GitHub Release with auto-extracted
  changelog, aggregate checksums and install instructions.
- **README updated for beta**: status changed from "Experimental
  alpha" to "Closed beta ready"; feature status table reflects current
  reality (voice/TURN/updates/backups now green, marketplace clearly
  labeled as reviewed-only with ADR link).
- **Beta release checklist** (docs/BETA_RELEASE.md): 19-point
  pre-beta verification (all complete), known limitations (5 ADRs),
  deployment commands, what to test in beta, issue reporting.

## [Unreleased] - backup manifest unification + real-Postgres ownership tests - 2026-09-16

### Fixed

- **Backup manifest formatVersion:1 (19th-audit P2)**: `lfctl backup
  create` now emits the canonical manifest alongside the dump — the
  same format `lfctl update apply --backup-manifest` and `lfctl
  backup verify` consume. The output feeds directly into the update
  flow without format conversion. The legacy sidecar (for restore
  checksum) is still written for backward compatibility.

### Added

- **Real-Postgres ownership integration tests (19th-audit)**: 10 tests
  against a real migrated Postgres covering the paths multiple audits
  flagged as mock-only — plugin catalog (first-submit regression,
  same-publisher update, foreign-publisher no-op, NULL-publisher
  legacy lock, concurrent unique constraint) and registry instances
  (first registrant ownership, foreign-user no-op, owner metadata
  update, NULL-owner legacy lock, concurrent unique constraint).
  Wired into the CI migration-check job's existing ephemeral Postgres.

## [Unreleased] - 19th-audit final fixes - 2026-09-16

### Fixed

- **Update runner safety gates (19th-audit P1)**: update apply now
  enforces updateAvailable, currentSupported, signature validity and
  --force-major for major upgrades BEFORE touching anything. Strict
  backup verification (requireFiles: true) is MANDATORY for the
  destructive path — not opt-in.
- **Update runner build step (19th-audit P1)**: `pull-images` step now
  runs `docker compose build --pull` instead of `docker compose pull`
  (lobbyforge-web:latest is a LOCALLY-BUILT image — pull would fail
  or pull a stale registry image).
- **Health-check step ID fixed (19th-audit P1)**: the runner now
  matches the plan's actual `health-check` ID (was looking for the
  non-existent `post-health-check`), and runs a REAL HTTP health
  check through the compose network instead of just `docker compose ps`.

## [Unreleased] - 19th-audit remediation - 2026-09-15

### Fixed

- **Update runner complete rewrite (P1)**: now builds the new
  LobbyForge image (docker compose build --pull, not just pull which
  fails on the locally-built lobbyforge-web), runs the ACTUAL health
  check (correct step ID 'health-check', real HTTP fetch), enforces
  ALL safety gates (updateAvailable, currentSupported, signature
  validity, --force-major for major upgrades), and uses STRICT backup
  verification (requireFiles mandatory, not opt-in).
- **LiveKit 1.13.6 → 1.13.7 (P1)**: the x/crypto CVE-2026-56854 is
  fixed upstream (v1.13.7 ships x/crypto v0.56.0); the .trivyignore
  exception is removed and the image digest updated in compose + Trivy.
- **Backup create emits canonical manifest (P2)**: `lfctl backup
  create` now produces a formatVersion:1 manifest alongside the dump —
  the output feeds directly into `lfctl update apply --backup-manifest`
  without format conversion.
- **SSE retry logic (P2)**: the failed-subscribe path now ACTUALLY
  retries (the old code just re-nulled without scheduling a
  reconnect, permanently disabling event-driven invalidation after a
  Redis failure at process start).
- **Dockerfile base digest-pinned (P2)**: node:22-bookworm-slim is
  pinned to @sha256 — the LobbyForge image build is now reproducible
  from the same commit.
- **Dependabot docker-compose (P3)**: configured for /infra/docker
  with weekly digest update PRs.
- **install.sh certbot digest-pinned (P3)**: the first TLS bootstrap
  now uses the same digest-pinned image as steady-state.
- **Version default corrected (P3)**: DEFAULT_CURRENT_VERSION updated
  to 0.2.0 (was 0.1.0).
- **ADR-003 status updated (P3)**: marked IMPLEMENTED (was "pre-
  release action").
- **lfctl help text updated (P3)**: no longer says "intentionally
  locked".

## [Unreleased] - Docker digest pinning + update automation - 2026-09-15

### Fixed

- **Docker digest pinning (ADR-003)**: all six production third-party
  images are now pinned to exact @sha256 digests (nginx, redis,
  postgres, livekit, coturn, certbot) — CI scans byte-identical to
  what production deploys. Renovate/Dependabot can auto-PR digest
  updates.
- **Update automation runner (18th-audit)**: `lfctl update apply
  --yes` now EXECUTES the plan instead of refusing — verified backup
  → pull images → recreate services with --wait → health check, with
  per-step failure handling and rollback command output. The old
  "refusing to execute" lock is removed.

## [Unreleased] - Architecture decisions - 2026-09-15

### Added

- **ADR-001..005** (docs/ARCHITECTURE_DECISIONS.md): five architecture
  decision records covering the remaining design choices — plugin
  runtime trust model (reviewed-only for beta, per-plugin containers
  for hostile), plugin client UI (server-only for beta, sandboxed
  iframe as target), Docker supply chain (moving tags for beta, digest
  pinning pre-release), GitHub governance (graduated, full lockdown
  pre-release) and desktop distribution security (defer signing to
  pre-distribution).
- **SECURITY.md** updated with the explicit reviewed-only trust model
  and its limitations.

## [Unreleased] - 18th-audit remediation - 2026-09-15

### Fixed

- **SSE singleton initialization race (MEDIUM)**: the old starting-flag
  flipped false BEFORE the subscribe() promise settled — burst SSE
  openings each saw (null, false) and created DUPLICATE Redis
  subscribers (leaked connections, duplicate fan-out). Replaced with a
  singleton Promise that guarantees exactly one duplicate() per process.
- **Directory proof persistence (HIGH-functional)**: the .well-known
  endpoint now reads the proof from the DB (migration 0035 adds
  directory_proof to instance_settings) instead of a manual Redis key.
  New owner-only POST /api/admin/directory/config atomically stores
  domain + publicKey + proof + enabled — the operator generates the
  proof with lfctl directory proof, submits via this endpoint, and the
  .well-known endpoint serves it immediately. No manual Redis writes.
- **Directory config query (LOW)**: explicit WHERE instanceId =
  'default' — a second row in the table can no longer silently change
  which config the .well-known endpoint serves.
- **Backup create/restore streaming (MEDIUM)**: both paths now use
  createReadStream + incremental SHA-256 instead of readFile — multi-GB
  dumps stay flat-memory throughout create, verify and restore.
- **Workflow comment truthfulness (LOW)**: the Trivy scan comment now
  correctly says CRITICAL+HIGH fail (it previously said only CRITICAL).
- **Desktop callback instance param (LOW)**: the redirectUrl now
  includes instance=<origin> so the native gate's origin check is
  always exercised.

## [Unreleased] - 17th-audit remediation - 2026-09-15

### Fixed

- **IPC message strict validation (P1)**: a hostile plugin sharing
  the process.send() primitive could send null, arrays or fabricated
  results — the parent accessed msg.log without checking, so
  process.send(null) crashed the ENTIRE plugin-worker service. The
  handler now validates every IPC message is a non-null, non-array
  object with a known field (log/error/result) before touching it;
  unknown shapes are killed with SIGKILL + clean error.
- **Process tree escape (P1)**: a plugin could spawn descendant
  processes (node:child_process) that survived the executor's SIGKILL.
  The executor now runs detached (own process group via setsid) and
  cleanup kills the ENTIRE GROUP via kill(-pid, SIGKILL) — descendants
  cannot outlive their parent.
- **.well-known route fixed (P1-functional)**: the previous
  implementation had a syntax error (calling an object as a function)
  AND read fields that don't exist in the schema. Rewritten with a
  dedicated getDirectoryVerificationConfig() query reading the actual
  instance_settings columns (instanceId, domain, publicKey,
  isPublicDirectoryEnabled).
- **lfctl directory proof command**: generates the Ed25519 proof the
  .well-known endpoint serves (lfctl directory proof --instance-id
  --domain --key-file).
- **Memory claim corrected**: --max-old-space-size=128 is a V8
  old-space cap, not a 128 MB hard process limit — the container's
  mem_limit: 256m (shared by parent + children) is the real ceiling.
  Documentation now states this accurately.

### Added

- **Adversarial plugin tests (3)**: process.send(null) parent-survival,
  process.exit(0) Promise-settlement, fake-result IPC hijack —
  proving the strict validation and settled-flag paths work under
  hostile input.

## [Unreleased] - 16th-audit remediation - 2026-09-15

### Fixed

- **Plugin storage capability wiring (P1-functional)**: the capability
  was minted into the ctx envelope but the worker looked for it at
  the RPC top level — production ctx.storage.* calls always 401'd.
  The client now destructures the envelope into (ctx snapshot,
  storageCapability) and sends the capability as a separate top-level
  RPC field.
- **Child process payload via IPC (P1-security)**: the payload was a
  single argv string — /proc/<pid>/cmdline exposed every sibling
  plugin's state, action payloads and scoped capabilities to any
  same-UID process (game confidentiality break), and Linux
  MAX_ARG_STRLEN (~128 KiB) silently capped large states (E2BIG).
  Now child.send() over the private IPC channel.
- **Early-exit Promise fix (P1-DoS)**: a plugin calling
  process.exit(0) sent no message, cleared the timeout without
  settling, and left the executor Promise pending forever (resource
  leak). A settled-flag pattern now guarantees settle-once on every
  path (result, error, timeout, clean exit, signal).
- **Legacy executor.mjs removed**: the old worker-thread variant was
  dead code — a future contributor could accidentally re-wire the
  weaker path. Build only ships executor-child.mjs.
- **Registry .well-known producer (P2-product)**: new
  GET /.well-known/lobbyforge-verification endpoint — self-host
  instances can now actually SERVE the document the official registry
  fetches (the verifier existed but the producer didn't; onboarding
  was impossible on a vanilla install).
- **Challenge canonicalization + atomicity (P2)**: the challenge
  endpoint now normalizes the domain the same way register does
  (trailing-slash variants produced different Redis keys), and uses
  SET NX for atomic create-if-absent (the old GET→SET race could
  invalidate a challenge the instant it was returned). The response
  includes the canonical domain for the client to sign.
- **Backup verify streaming hash (P3)**: createReadStream +
  incremental SHA-256 instead of readFile — multi-GB dumps no longer
  load entirely into memory.
- **CSP E2E interaction (P3)**: the hydration test now clicks a
  visible element and waits for the UI to respond — real proof that
  React hydrated, not just that server HTML rendered.
- **Trivy rationale corrected**: the pcre2 description now correctly
  attributes it as a transitive Debian base-layer library (NOT
  Node/V8 — V8 uses Irregexp), reachable only via pcre2-linked system
  utilities.

## [Unreleased] - 15th-audit follow-ups: backup hash, child-process isolation - 2026-09-15

### Fixed

- **Backup verify actually verifies (15th-audit P3)**: `lfctl backup
  verify --require-files` now reads the dump bytes, computes the real
  SHA-256 and compares against the manifest (plus exact size). The
  old check only confirmed the file existed and the manifest hash
  LOOKED like a SHA-256 — "Backup Verified: yes" was misleading for
  corrupted dumps.
- **Plugin executor → CHILD PROCESS (15th-audit finding 5)**:
  plugin code now runs in a dedicated child process (fork) instead of
  a worker thread. This is a REAL OS boundary: the child's
  /proc/<pid>/environ is empty (worker threads shared the parent's
  startup env via /proc/self/environ — PLUGIN_WORKER_TOKEN was
  readable), kill(SIGKILL) is a hard termination the child cannot
  intercept, and --max-old-space-size enforces memory. The infinite-
  loop termination test now proves the process-kill path.
- **Build copies both executor variants** (thread + child) to dist.

## [Unreleased] - 15th-audit remediation - 2026-09-15

### Fixed

- **Registry account-bound challenge (P1)**: the .well-known domain
  proof was PUBLIC — an attacker who saw the document could replay
  the same instanceId+domain+publicKey tuple and become the first
  registrant. Registration now requires an ACCOUNT-BOUND nonce:
  GET /register/challenge issues one stored against (userId,
  instanceId, domain); the instance signs {register, nonce,
  instanceId, domain} with the private key; only that user's
  submission can consume it. 17 tests.
- **Marketplace first-submit bug (P1)**: the NULL-publisher lock fired
  for BOTH "no row" (brand-new plugin → INSERT should succeed) and
  "legacy row with NULL publisher" (locked) — every FIRST community
  submission was rejected with PluginIdTakenError. The fix gates the
  NULL check behind existing.length > 0; the concurrent race loser
  now gets a clean 409 instead of an undefined row. Structural
  regression tests.
- **CSP nonce request propagation (P1)**: the middleware now passes
  the nonce AND CSP to the renderer via NextResponse.next({ request:
  { headers } }) — Next.js reads x-nonce from the REQUEST to inject
  nonces into framework/bootstrap scripts. The old code only set the
  response headers; production hydration could break under strict
  CSP. A hydration E2E test was added to the prod-TLS suite (CSP
  nonce present, no unsafe-inline, no console errors, DOM
  interactive).
- **Heartbeat staleness filtering (P2)**: public directory listings
  now require lastHeartbeatAt within 10 minutes — dead instances
  whose domains expire and get re-registered no longer stay listed
  forever sending users to a domain the operator no longer controls.
- **TURN IPv6 ranges (P2)**: coturn's denied-peer-ip now covers the
  full IPv6 private space (::, fc00::/7, fe80::/10, ff00::/8) —
  previously only ::1 was denied.
- **Directory limit validation (P3)**: strict integer 1-200 — NaN,
  negatives and fractional values now default to 50 instead of
  reaching the query.
- **Trivy rationale correction (P3)**: the web-image pcre2 CVE
  description incorrectly claimed Node's RegExp uses pcre2 — V8 uses
  Irregexp. The rationale now correctly attributes pcre2 as a
  transitive base-layer library, not a Node dependency.

## [Unreleased] - 14th-audit remediation - 2026-09-10

### Fixed

- **SSRF transport Node-22 incompatibility (P1)**: the directory's
  private ssrfSafeGet had a bare-string lookup callback — Node 22's
  autoSelectFamily passes all:true and expects {address, family}
  objects, so the IP pinning silently misbehaved. All server-side
  HTTPS fetches (plugin installer, marketplace review-pinning,
  directory domain verification) now share ONE transport
  (lib/ip-pinned-https.ts) with the correct lookup shape — the exact
  implementation the installer had already proven.
- **Legacy unpinned plugins fail CLOSED (P1)**: install refuses
  approved rows without a bundle pin (409, re-review required) and
  migration 0034 pushes every legacy approved-but-unpinned row back to
  pending — the mutable-URL trust gap no longer survives for
  pre-pinning approvals. Test added.
- **Tar archive validation actually validates (P1)**: the old `tar
  -tv` human-output regex did not match REAL GNU tar lines, so the
  decompression-bomb, symlink/hardlink/device/FIFO and traversal
  pre-checks silently skipped EVERY entry. Replaced with a
  programmatic ustar header parser (512-byte blocks, GNU base-256
  sizes) — bounded in-memory gunzip first, every rule counts every
  entry. 7 tests against hand-built archives including the bomb and
  symlink cases the regex missed.
- **API cleanup**: the change-domain schema's unused domainProof
  field removed (the document's own proof is the single source).

## [Unreleased] - 13th-audit remediation - 2026-09-09

### Fixed

- **Registry domain immutability + change flow (P1)**: /register no
  longer mutates the domain on existing rows — its proof only ties the
  REQUEST's key to the new domain, so a hijacked owner session could
  previously redirect a listed instance to a self-verified attacker
  domain. New POST /api/directory/change-domain applies the rotate-key
  model to domains: owner session + a canonical-payload proof signed
  with the CURRENT stored private key + a valid .well-known document
  on the NEW domain + timestamp window + one-time nonce.
- **Marketplace artifact pinning (P1)**: approval now FETCHES the
  bundle (via the installer's hardened downloader — one code path for
  review and install) and records bundleSha256 + bundleSizeBytes
  (migration 0033); install verifies downloaded bytes against the pin
  constant-time BEFORE touching the filesystem. A compromised
  publisher host can no longer swap approved code behind an unchanged
  catalog row. 3 new tests (pin passed, fetch-failure refusal,
  review-call assertion).
- **Reviewer FK bug**: the marketplace review route's all-zero
  reviewer UUID would violate the users FK on real Postgres (approve →
  500). Token-based reviews now record NULL (column semantics
  documented); session-based admin reviews attribute their uid.
- **Proof single-sourcing**: registration and change-domain verify the
  DOCUMENT's own proof (doc.proof) instead of a redundant
  request-carried twin — one proof source, fewer future footguns.

## [Unreleased] - 12th-audit remediation - 2026-09-09

### Fixed

- **REAL directory domain proof**: the 11th-audit key challenge only
  proved a signature matched the request's OWN publicKey — an attacker
  simply used their own keypair and squatted any instanceId.
  Registration now verifies DOMAIN OWNERSHIP server-side: the
  directory fetches https://{domain}/.well-known/
  lobbyforge-verification over a new SSRF-safe IP-pinned HTTPS client
  (hostname checks + all-addresses-public DNS resolution + connection
  pinned to the verified IPs; extracted from the plugin installer's
  battle-tested pattern) and requires the served document to carry the
  same instanceId + publicKey + a valid Ed25519 proof over the
  canonical payload. 3 tests including the attacker-keypair case.
- **Registry IPv6 canonical classification**: the standalone registry
  package used the old prefix checks (fe90/fea0/feb0 link-locals and
  all IPv4-mapped forms passed). It now vendors the canonical
  BigInt/CIDR parser — with hostname-appropriate semantics (a
  non-literal hostname is not auto-blocked pre-resolution; literal
  garbage still refuses). 10 new regression tests. This was a
  prerequisite: the domain proof's server-side fetch would have
  inherited the hole as live SSRF.
- **Branch-protection check names**: the Trivy matrix jobs got STABLE
  names (Third-party Trivy (nginx), …) — GitHub appends every matrix
  field to the generated check name, so the old image-tag contexts
  could never match (a permanent merge blocker on contributor PRs).
  Protection updated to the exact stable names.
- **TURN env name**: .env.prod.example carried TURN_AUTH_SECRET but
  the web app reads LOBBYFORGE_TURN_SECRET — manual deploys silently
  lost the TURN fallback on restrictive networks. Renamed + a manual
  deploy note (render-configs.sh) added.
- **SSE invalidation resilience**: a failed initial Redis subscribe no
  longer latches a dead subscriber (which silently downgraded every
  stream to the 30s recheck) — exponential backoff retry, 500ms→30s.
- **Registration race loser UX**: the setWhere no-op now throws
  RegistryInstanceOwnedError (403) instead of returning an undefined
  row that 500'd downstream.

## [Unreleased] - 11th-audit remediation - 2026-09-09

### Fixed

- **SSE invalidation connection exhaustion**: the per-stream
  Redis duplicate() is gone — ONE process-wide subscriber now
  multiplexes a handler Set (registering costs a Set entry, not a
  connection). This was the exact surface the activity bus had already
  solved; the first invalidation cut re-introduced it (1 SSE ≈ 1
  hordable subscriber).
- **Plugin-storage rate-limit poisoning**: the machine wrapper's
  optional rateScope extracts the bucket from the AUTHENTICATED
  capability — plugin-storage now limits per (serverId, pluginId)
  instead of the IP-shaped 'unknown' bucket every worker call shared.
  A malicious plugin flooding its own quota no longer 429s every other
  plugin's storage.
- **Directory registration ownership proof**: registration now
  requires a KEY CHALLENGE — GET /api/directory/register/challenge
  issues a one-time 10-min nonce (Redis GETDEL); the instance signs it
  with the PRIVATE key matching the submitted publicKey; the route
  verifies before upsert. InstanceId squatting / registration DoS by
  unrelated users is closed (2 tests: wrong signature, expired
  challenge).
- **Registration concurrency race**: the ownership decision moved
  INSIDE the atomic statement — the conflict-update carries
  setWhere(owner = excluded.owner), so the concurrent race loser's
  metadata write is a no-op instead of rewriting the winner's row.
- **Desktop login DoS**: accept_deep_link now VERIFIES FIRST and
  consumes the pending entry only on success — a wrong-state link
  could previously burn the pending login so the REAL deep link never
  matched.
- **Deployment consistency**: .env.prod.example synced with every
  variable the production compose interpolates (worker/storage tokens,
  TURN secret, ACME/domain, trusted-proxy); install.sh's initial
  certbot run bumped v2.11.0 → v5.8.0 (the compose image had moved but
  the installer path still ran the EOL base).
- **Scan hygiene**: the livekit Trivy ignore no longer carries the
  postgres CVE (it belongs to .trivyignore.d/postgres only); branch
  protection now requires the full security pipeline (CodeQL, web +
  per-image Trivy, RustSec, both e2e suites) in addition to the CI
  gates.

## [Unreleased] - 10th-audit remediation - 2026-09-09

### Fixed

- **Desktop native state binding (P1)**: the state is now generated
  and held NATIVELY. `begin_desktop_login` (Rust command) mints a
  192-bit state bound to the connected instance (30-min TTL,
  single-use) and opens the system browser at
  /login?desktopLoginState=…; the login page passes it into the
  handoff mint. EVERY lobbyforge:// deep link — OS-routed or carried
  in a second instance's argv — now passes accept_deep_link(): pending
  entry exists, unexpired, state constant-time-equal, instance origin
  matching, consumed on use. Unsolicited attacker links die at the OS
  boundary; the web-side checks remain as defense-in-depth.
- **Plugin parent import (P1)**: the plugin-worker parent process no
  longer import()s ANY bundle (ESM import runs top-level code — a
  malicious plugin could previously block the service, read
  process.env or monkeypatch globals before any executor thread
  existed). The parent tracks filesystem paths only; even the plugin
  LIST is produced by a disposable describe-executor thread. Importing
  happens exclusively inside terminated-on-timeout threads.
- **Central membership gate (P1)**: requireVisibleChannelInServer now
  enforces authenticated → server MEMBER → channel binding →
  visibility in one place. This closes the systemic "visible treated
  as member" hole for every caller (typing, presence, …); the
  single-channel GET additionally flipped its requireMembership flag
  (non-members could read public-channel metadata by id).
- **Presence leak (P1)**: channel presence now applies the same
  visibility policy (404 keeps private-room existence quiet) — a plain
  member can no longer see WHO is in a private room by guessing ids.
- **Directory NULL-owner claim (P1)**: legacy owner-less rows are no
  longer claimable via self-service upsert (RegistryInstanceUnclaimable
  Error → 403); migration 0032 unlists every unowned row until an
  administrator recovers it — listed-but-unvouchable discovery entries
  were a phishing vector.
- **Voice mute hierarchy (P2)**: voice_mute joined the canonical
  moderation gate (MUTE_MEMBERS + strict actor>target ranking) — a
  rank-30 moderator can no longer mute a rank-80 admin.
- **SSE revocation latency (P2)**: SSE streams subscribe to the
  access-invalidation bus (shared with the ws-gateway) and abort the
  INSTANT a matching kick/role/channel-policy event lands; the 30s
  keepalive recheck remains as the lost-message safety net.
- **Directory key rotation (P2)**: POST /api/directory/rotate-key —
  owner session + proof signature made with the CURRENT private key
  over the canonical rotation payload + timestamp window + nonce
  replay guard. A stolen key can be rotated out; a stolen session
  alone cannot swap keys. Lost-key recovery stays an admin flow.
- **CI truthfulness**: our web-image Trivy gate actually runs
  CRITICAL,HIGH (the comment claimed it, the command didn't);
  third-party ignores split into per-image files (.trivyignore.d/) so
  an accepted livekit CVE can never silence another image's scan.

### Added

- **main branch protection**: required status checks (verify x2,
  docker build, compose config, prod audit) + force-push/deletion
  disabled; the owner keeps an admin escape hatch.

## [Unreleased] - 9th-audit remediation - 2026-09-09

### Fixed

- **Desktop session swapping (P1)**: the handoff listener now binds to
  `event.source === window` + `event.origin === location.origin` — the
  shell injects the callback INSIDE the page, so a genuine handoff
  always satisfies both; no external window can. One consume attempt
  per page load. (The old listener accepted any postMessage: an
  attacker could feed their own code+state into the victim's tab and
  swap the victim onto the attacker's session.)
- **SSE post-kick access (P1)**: new canonical
  denyActivityStreamAccess (server + owner-or-MEMBERSHIP + session
  binding + channel visibility) drives BOTH stream-open and the 30s
  revalidation. The old recheck ran visibility-only, whose underlying
  canMemberAccessChannel returns true for everyone on no-override
  channels — a kicked user's stream kept flowing. 5 tests incl. the
  kicked-user regression.
- **WS authorization durability (P1)**: Redis Pub/Sub is fire-and-forget
  — a lost invalidation left stale authorization forever. The gateway
  now ALSO runs a periodic FULL reauthorization of every live topic
  (default 30s, WS_REAUTH_INTERVAL_MS) with access_revoked + removal on
  denial. role DELETE now publishes the same server-policy invalidation
  PATCH does (it strips permissions from every holder too).
- **Channel metadata leak + permission bug (P1)**: GET
  /channels/:channelId applies the same visibility policy as the list
  (404 — existence never leaks); PATCH/DELETE no longer require
  OWNERSHIP before the real MANAGE_CHANNELS check (moderators with the
  permission were 403'd).
- **Machine endpoints 403 in production (P2)**: new
  withMachineApiSecurity (method/body/rate/headers — NO browser origin
  guard; strong machine auth inside the handler is the contract). The
  Ed25519 heartbeat and the plugin-storage endpoint use it: signed
  lfctl heartbeats reached originGuard's "Missing request origin" 403
  before verification ever ran.
- **DM reply integrity (P2)**: replyToId must reference a message in
  the SAME conversation (400 otherwise) — cross-channel reply targets
  broke DB integrity and would leak on future hydration.
- **Heartbeat replay window (P3)**: nonce TTL now covers the full
  timestamp acceptance window (2×skew + slack) — a captured heartbeat
  can no longer outlive its nonce.
- **Plugin worker isolation (findings 4+5)**: plugin code now executes
  in a dedicated worker_thread with **env: {}** (no worker secret is
  readable — not even the RPC token), heap/young-gen caps, and
  **terminate() on timeout** — a `while(true)` plugin is killed, not
  merely out-raced (Promise timeouts can't fire on a blocked loop; the
  test proves termination + service health). Storage access switched
  from a global worker token to HOST-MINTED scoped capabilities
  (HMAC(serverId|pluginId|expiry), 120s TTL): the worker only relays
  them, and the endpoint verifies capability-vs-request-scope — a
  stolen relay cannot address any other keyspace. 6 capability tests +
  the infinite-loop termination test.

### Added

- **cargo-audit CI job** (RustSec scan of the desktop Cargo.lock — the
  Node-side gates cover a different dependency graph).
- **Third-party image Trivy scans** for the pinned nginx/redis/
  postgres/livekit/coturn/certbot images the production stack deploys.

## [Unreleased] - 8th-audit acceptance-criteria sweep - 2026-09-08

### Added

- **LF-SEC-009 test coverage** (done criteria: "tests simulate outage
  and recovery"): tri-state unit tests with a controllable ioredis
  fake — active/revoked/unavailable, and a recovery case proving one
  error does NOT latch. Plus REAL-socket integration tests through
  createGateway: production handshakes fail CLOSED on outage (1011),
  confirmed revocations close 1008, development tolerates outage.
- **LF-SEC-003 integration test** (done criteria: "regression tests
  cover open-connection revocation"): a live socket subscribes, an
  invalidation event fires, the re-authorization denies → the client
  receives `access_revoked` AND a later bus event is provably not
  delivered (the mocked release mirrors real Redis-unsubscribe
  semantics); an unrelated user's subscription survives the same
  event. createGateway now exposes its HTTP server for ephemeral-port
  testing and WS_PORT=0 is accepted.
- **LF-SEC-008 client half**: the web app actually CONSUMES the
  lobbyforge://session/complete deep link now — a root-layout listener
  handles the shell's postMessage, validates the URL shape, and burns
  the one-time code ALWAYS sending {code, state} (11 tests). The
  consumer side of the handoff loop simply did not exist before.
- **LF-SEC-012 edge test**: the prod-TLS e2e now proves the per-route
  nginx caps — a 7 MiB body on /api/users/me/avatar passes the edge
  (app answers 401) while the SAME body on a normal API path is 413'd
  by the edge.

## [Unreleased] - original plan tail: Faz D/G completion - 2026-09-08

### Added

- **Faz D (reporting loop closed)**: the discovery card now carries a
  report button (flag, hover-revealed) with a reason/detail dialog that
  posts to the existing /api/directory/:id/report endpoint — and the
  admin moderation panel gained an "Instance Reports" section (pending
  first, reporter attribution, reason badges) with dismiss/actioned
  resolution. The moderation API actually GAINED its missing POST
  handler (the client had been calling a 405): instance
  list/unlist/block and report resolution, admin-gated, decisions
  attributed to the acting admin session. New DB queries:
  listInstanceReports / setInstanceReportStatus (pending-only
  transitions). 7 API tests.
- **Faz G (discovery i18n)**: the discovery page resolves the visitor
  locale (Accept-Language, en/tr) and pre-translates all labels with
  the @lobbyforge/i18n translator — the grid had been hardcoded
  English while the keys sat unused. 17 new en/tr key pairs (report
  dialog, counts, empty states), full parity maintained.

## [Unreleased] - LF-SEC-010 long-term: isolated plugin-worker runtime - 2026-09-08

### Added

- **Isolated runtime for marketplace plugins**: third-party plugin
  code now executes in a dedicated `plugin-worker` container — a
  separate process with NO host secrets, a read-only root filesystem,
  a read-only bundle mount, mem/pid caps, dropped capabilities,
  no-new-privileges, an internal-only network and a healthcheck-gated
  restart. The web app talks to it over a token-authenticated /rpc
  endpoint with hard timeouts: a hung or malicious plugin can at worst
  restart the worker, never the web app.
- **Snapshot ctx + storage capability proxy**: the worker rebuilds the
  plugin context from a SNAPSHOT envelope (actor, players, voice);
  `ctx.storage.*` executes back on the host via the internal
  `/api/internal/plugin-storage` endpoint (404 at the public nginx
  edge, constant-time token), hard-scoped to the plugin's own
  (serverId, pluginId) keyspace — the worker holds no DB credentials.
- Loader integration: dynamic execution requires BOTH the feature flag
  AND LOBBYFORGE_PLUGIN_WORKER_URL (the flag alone loads nothing —
  fail closed), and the in-process import path for third-party code is
  REMOVED from the loader. Workspace plugins (hushle, quiz) remain
  trusted in-process via the static registry.
- Compose wiring: shared plugins-data volume (web rw / worker ro),
  tokens generated by install.sh, worker built into the production
  image.
- Tests: 10 worker RPC tests (real HTTP server + fixture bundles —
  token auth, snapshot ctx, storage fail-closed on unreachable host,
  size caps) + 3 loader gate tests (flag-without-URL loads nothing,
  worker-mode loads with ZERO filesystem access, unreachable worker
  fails closed).

## [Unreleased] - 8th-audit follow-ups: SSE revalidation + heartbeat sender - 2026-09-08

### Fixed

- **LF-SEC-003 (SSE half)**: the activity-stream keepalive now re-runs
  the SAME membership + channel-visibility gate as stream open every
  30s — a kick or role removal closes an open SSE mid-stream (a
  transient DB error does not flap the stream; REST stays the strict
  gate).

### Added

- **LF-SEC-007 (sender half)**: `lfctl directory keygen` generates the
  instance Ed25519 keypair (private key mode 600; prints the base64
  SPKI to submit at registration) and `lfctl directory heartbeat`
  signs + POSTs heartbeats (`--once` or `--interval` loop, fresh nonce
  every send). The signing logic is a self-contained twin of the
  tested lib/directory-heartbeat.ts (canonical key order pinned by
  tests on both the sender and the server side) — the signed directory
  loop is now usable end-to-end.

## [Unreleased] - 8th-audit remediation (LF-SEC-001..015) - 2026-09-08

### Fixed

- **LF-SEC-001 (P0) Next.js security patch**: 16.3.1 → 16.3.4 (the
  advisory range is fixed in ≥16.3.3); eslint-config-next and
  @next/eslint-plugin-next aligned to 16.3.4.
- **LF-SEC-002 single-message bypass**: the [messageId] route now runs
  the SAME canonical policy as the message list (membership +
  role-gated visibility + READ_MESSAGE_HISTORY) via the new
  lib/message-authorization.ts (authorizeChannelMessageAccess:
  read/send/mutate). Mutations additionally require
  author-or-MANAGE_MESSAGES and NEVER bypass channel visibility via
  authorship. 11 regression tests including role-removal-after-ID-known.
- **LF-SEC-004 role hierarchy**: lib/member-authorization.ts
  (authorizeModerationTarget) now enforces actor-vs-TARGET hierarchy
  (plus MANAGE_ROLES, owner protection, target membership) — a
  lower-ranked manager can no longer strip a higher-ranked user's
  roles with roleIds: []. 9 tests.
- **LF-SEC-005 kick/ban hierarchy**: kick, ban and timeout all funnel
  through the SAME canonical moderation gate (kick=KICK_MEMBERS,
  ban=BAN_MEMBERS, timeout=MODERATE_MEMBERS + strict actor>target
  ranking). Matrix tests across owner/equal/lower/higher.
- **LF-SEC-006 DM block**: checkDmChannelAccess (canonical, in
  @lobbyforge/db) — send/subscribe require participant AND no block in
  either direction, on EXISTING channels too; history stays readable
  (documented product policy). REST + WS subscribe use the same helper.
- **LF-SEC-003 realtime stale authorization**: an access-invalidation
  Redis bus (lib/access-invalidation.ts) — kick/ban/role changes,
  channel-policy edits, role permission edits and blocks publish
  events; the ws-gateway re-runs authorizeTopicSubscribe for the
  affected live subscriptions, removes failing ones and sends
  `access_revoked` to the client. Blast-radius matching unit-tested.
- **LF-SEC-009 revocation fail-open**: tri-state
  getRevocationStatus('active'|'revoked'|'unavailable') — production
  handshakes REJECT when the store is unreachable (REST parity), live
  sockets get a bounded 60s grace then close; the permanent
  failed-client latch is gone (ioredis reconnects).
- **LF-SEC-007 heartbeat spoofing**: heartbeats are Ed25519-SIGNED
  against the registered publicKey (canonical JSON, ±300s window,
  Redis nonce replay guard). A human cookie alone can no longer
  update another instance's public stats. 7 tests incl. cross-instance
  spoof, replay, stale/future timestamps.
- **LF-SEC-008 desktop handoff**: completion now uses Redis GETDEL
  (atomic consume — parallel completions yield exactly one winner),
  REQUIRES and constant-time verifies the state, and a burned code
  stays burned on wrong-state/deleted-account. 10 tests.
- **LF-SEC-011 ban visibility (Policy A)**: the ban list is now
  readable only by the owner or BAN_MEMBERS/MODERATE_MEMBERS/
  VIEW_AUDIT_LOG holders — ordinary members no longer see moderation
  metadata.
- **LF-SEC-012 upload limits**: nginx route-specific caps raised to
  12m for avatar/banner/server-banner/instance-logo — the app accepts
  data-URL strings up to 8 MiB, which the old flat 6m rejected at the
  edge (and the server-banner route had no location at all).
- **LF-SEC-013 IPv6 classification**: lib/ip-ranges.ts — addresses
  are PARSED to 128-bit BigInts and checked against explicit CIDRs
  (no string prefixes). Full audit test table passes: fe80::/10 in
  every notation, IPv4-mapped/translated embedded-policy, CGNAT,
  ULA, multicast, NAT64, documentation ranges; unparseable → blocked.
  The plugin installer now uses it.
- **LF-SEC-014 body guard**: enforceBodyLimit in withApiSecurity —
  streamed bodies (no trustworthy Content-Length) are read with a
  byte counter and genuinely 413'd; the handler receives a bounded
  replacement Request. 5 tests.
- **LF-SEC-010 (short-term)**: the dynamic plugin loader's env-scrub
  restore moved into finally — a thrown import can no longer leave
  the host process without its secrets. The feature stays
  default-off (SEC-008 wave-5 gates remain pinned).

### Added

- **LF-SEC-015**: a scheduled+push dependency-audit job in the
  security workflow (lockfile-aware pnpm audit, prod deps,
  moderate+ gate) — advisories against an UNCHANGED lockfile now
  surface weekly, not only on merges.

## [Unreleased] - 7th-audit remediation wave 5: final findings - 2026-09-07

### Added

- **TEST-001 production TLS e2e (CI)**: a new `e2e-prod-tls` job boots
  the REAL production compose stack (nginx + web + ws-gateway + livekit
  + postgres + redis, rendered configs, boot migrations) behind an
  nginx TLS edge with a self-signed certificate, then proves the edge a
  production user hits: HTTPS health through nginx, the HTTP→HTTPS
  redirect, the landing page over HTTPS, and the WSS /ws upgrade into
  the gateway (opens, then closes 4401 unauthenticated). A CI compose
  override keeps the 10k-port RTC UDP range off the shared runner.
- **DESK-001 desktop compile gate (CI)**: `desktop-rust` runs cargo
  check for the Tauri shell on the Windows runner — the Rust side had
  no CI and broke only at local `tauri dev`.

### Fixed

- **DESK-001 deep-link argv relay**: on Windows/Linux a
  `lobbyforge://session/complete?…` link opened while the app runs
  arrives as a command-line argument in a SECOND process, which the
  single-instance plugin immediately exits — the login handoff was
  silently lost. The single-instance callback now relays the URL
  through the same postMessage bridge as the deep-link event.
- **Six real deploy bugs the TLS e2e flushed out** (none of them
  reachable from the dev stack, all of them live in every fresh
  production install):
  1. livekit-server docker tags are v-prefixed — bare `1.13.6` does
     not exist on Docker Hub.
  2. the prod migrate one-shot ran from `/app`, so drizzle's
     `./drizzle` never resolved and migrations exited 1 — web (which
     waits on migrate) never started on ANY fresh install.
  3. web + ws-gateway healthchecks used `wget`, absent from
     node:22-bookworm-slim — every check failed instantly and
     `up --wait` never converged. Probes now use node's global fetch.
  4. the livekit healthcheck needed `busybox wget` (applet, not on
     PATH in the livekit image).
  5. livekit 1.13 removed `rtc.enable_loopback_reflection` — the
     template kept the field and livekit crash-looped at config parse.
  6. livekit 1.13 parses keys strictly (`"key: secret"`, space
     included) — the space-less LIVEKIT_KEYS interpolation crash-looped
     the server; and nginx's config statically proxies THREE upstreams
     but only depended on web, so a stack gap made nginx exit at
     config load ("host not found in upstream") — nginx now depends on
     web + ws-gateway + livekit, all healthy.

### Security

- **SEC-008 pinned + documented**: dynamic plugin execution stays
  disabled by default. New regression tests pin BOTH gates (the
  marketplace install 503 and the boot loader's no-op-without-flag,
  asserting zero filesystem access), and SECURITY.md now states the
  isolation requirement explicitly: the SDK contract is synchronous,
  so real isolation means a worker-thread/process RPC migration — an
  intentional protocol change, not a config flip.

## [Unreleased] - 7th-audit remediation wave 4: remaining security findings - 2026-09-04

### Fixed

- **SEC-007 directory instance ownership**: registry_instances had no
  owner — any authenticated user could upsert over an existing,
  admin-listed instance (name/domain swap = discovery phishing
  vector). Migration 0030 adds owner_user_id; the first registrant
  owns the entry, upserts from anyone else throw
  RegistryInstanceOwnedError → 403. Legacy NULL-owner rows are claimed
  once by the first updater, then locked. 3 new tests.
- **SEC-010 unbounded image storage**: every upload endpoint checked a
  per-request size but nothing bounded the TOTAL data-URL bytes one
  account could pin in Postgres. New per-user quota (24 MiB) counts
  avatar + profile banner + owned server banners and rejects with 413
  (removals always pass); the instance logo stays uncapped — it is a
  single self-replacing 2 MB-max row.
- **VOICE-001 permanent shared TURN credential**: coturn ran a static
  `lobbyforge:<secret>` user and livekit.yaml handed that SAME
  credential to every community member via rtc.turn_servers — anyone
  who ever joined a voice channel could relay forever. coturn now runs
  REST-auth (`use-auth-secret`); /api/livekit/token mints a per-user
  time-limited credential (`expiry:userId` + HMAC-SHA1, 1h = token
  TTL) and clients apply it via rtcConfig.iceServers; livekit.yaml
  ships NO static turn_servers anymore (regression-tested), and
  coturn's user-quota counts per user again (8). Docs + smoke-test
  recipe updated.
- **VOICE-002 stale pinned images**: livekit-server 1.8.3 → 1.13.6
  (1.10 fixed gRPC CVE-2026-33186; 1.12+ hardened TURN auth; 1.13.6
  adds signal body limits + per-participant TURN allocation caps);
  coturn 4.6.2 → 4.17.2 (security fixes across 4.10-4.14; DTLS
  opt-in does not affect our TLS listener). Dev stack already floats
  latest; the two-client voice e2e in CI exercises the new images.

### Verified (no change needed)

- **OPS-004 WS readiness**: the ws-gateway already serves /health on
  :3001 and docker-compose.prod.yml has a wget healthcheck wired to
  it — closed in an earlier wave; re-confirmed.

## [Unreleased] - 7th-audit (re-audit) remediation wave 3 - 2026-09-04

### Fixed

- **OPS-006 duplicate compose key**: docker-compose.prod.yml's turn
  service had TWO `volumes:` mappings — YAML silently keeps the last,
  so the cert mount (and any earlier entry) was dropped from the
  rendered config. Merged into one block; a new `compose-config` CI job
  now runs `docker compose config` on every push so this class of
  regression fails the build, not the deploy.
- **SEC-002 GET gap**: the activity-session GET handler imported
  authorizeSessionChannelVisibility but never called it — membership
  alone could read a session sitting in a role-gated channel. Now
  enforced (owner/manage_channels bypass, same policy as action/SSE/
  end); 2 new tests cover the plain-member 403 and the owner bypass.
- **SEC-003 WS revocation hardening (3 parts)**: (1) revoked-gid
  handshakes leaked an IP rate-limit slot — now released on rejection;
  (2) isGuestSessionRevoked uses a SHARED Redis client instead of
  connect-per-call (revocation-check floods no longer mint
  connections); (3) the heartbeat loop re-checks revocation for live
  sockets, so logout closes already-established WebSockets.
- **SEC-006 concurrent plugin-ID takeover**: the read-then-upsert race
  (two publishers, same pluginId, NULL owner row) is closed atomically
  — the conditional upsert carries `setWhere` on the publisher, and a
  NULL-owner locked row rejects a different publisher outright.
- **SEC-009 download memory-bomb + IPv6 gaps**: the IP-pinned plugin
  download enforces a 16 MiB streaming cap (destroy mid-chunk) instead
  of trusting Content-Length; link-local fe80::/10 covered in full;
  IPv4-mapped/translated IPv6 forms are expanded and checked.
- **SEC-004 spoofable session IPs**: resolveClientIp no longer trusts
  x-forwarded-for implicitly in production — only with an explicit
  LOBBYFORGE_TRUSTED_PROXY=x-forwarded-for opt-in, and then the LAST
  hop (the one the proxy appended), never the client-controllable
  first entry.
- **OPS-002 optional verifier**: the admin update apply/rollback paths
  now ALWAYS require the file-exists server-side check; the client
  flag in the request body is ignored.
- **RT-001 SSE enqueue leak**: a failed ping enqueue aborted the
  closed flag but leaked the interval + Redis subscription; the catch
  now aborts the stream for real.
- **CODE-001 literal NUL byte**: oauth-redirect.ts contained a raw
  0x00 inside a RegExp character class (written through a tool that
  converted the escape); now a clean `\u0000` escape. Repo-wide scan
  confirms no NUL bytes remain in any source file.

## [Unreleased] - 6th-audit wave 2: remaining P1s + P2s - 2026-09-03

### Fixed (P1)

- **SEC-006 marketplace plugin-ID takeover**: the pluginId is now an
  IDENTITY, not a slot — the first publisher owns it; a submit from a
  different user throws PluginIdTakenError → 409 (tested). A legitimate
  new version from the owner resets reviewStatus to pending (changed
  code must be re-reviewed).
- **VOICE-003 voice limits fail-open**: the hard room cap
  (defaultUserLimit) fails CLOSED (retryable 503) when the participant
  count cannot be verified — fail-open would have admitted everyone
  during an API blip; camera/screen-share caps degrade to DENIED
  (fewer publishers, never more). 3 tests including the replaced
  fail-open one.
- **SEC-009 plugin download TOCTOU/DNS-rebinding**: the connection is
  now PINNED to the pre-verified IPs via node:https Agent's custom
  `lookup` (re-resolution is refused; SNI/cert validation keep the
  original hostname). Private-range coverage expanded: IPv4 CGNAT
  100.64/10 + 192.0.0.0/24 + 198.18.0.0/15; IPv6 site-local,
  multicast, documentation, NAT64, discard-only.

### Fixed (P2)

- **SEC-011 host-header redirect**: default vhost returns 444 for any
  Host we don't serve; the HTTP→HTTPS redirect uses the canonical
  domain, not $host.
- **OPS-003 update path**: `git pull && bash install.sh` against a
  running same-domain stack now offers a menu — update (rebuild +
  recreate + migrations) / renew certs / exit — instead of exiting with
  "nothing to install". Scenario-2 installer test updated to the menu.
- **OPS-005 domain validation**: full DNS label rules (1-63 chars per
  label, alphanumeric start/end, hyphens inside only, FQDN ≤ 253);
  `a..b.com` and `bad-.com` now rejected (verified).
- **DOC-001 SFU reality**: the voice doc no longer implies
  client-to-client P2P — LiveKit is an SFU; all media flows through
  the server (N up + N×(N-1) down in a full-listen room); TURN is an
  additional relay layer. Capacity-implication note added.

## [Unreleased] - 6th-audit P0/P1 remediation (SEC/OPS) - 2026-09-03

### Fixed — the three P0 release blockers

- **SEC-001 — raw game state leaked over WebSocket**: the Redis activity
  bus shipped the CANONICAL state to every subscriber — a normal
  member's WS frames contained Hushle's deck/currentCard and Quiz's
  correctIndex. The bus wire format now carries ONLY status/revision/
  counts (regression test asserts the raw payload contains no
  currentCard/forbiddenWords/correctIndex); the ws-gateway loads the
  session and applies the CANONICAL projector per subscriber (moved to
  @lobbyforge/core so REST/SSE/WS share one implementation); the SSE
  route does the same on live events. Fail-closed: projection errors
  never forward unprojected state.
- **SEC-002 — private-channel rules skipped on WS + activity APIs**:
  gateway chat/activity-state topics now load the channel (directly, or
  via the session's channelId) and enforce role-gated visibility —
  owner/manage_channels bypass, same as REST. Every activity lifecycle
  route (list/GET/action/SSE/end) gates through
  authorizeSessionChannelVisibility; the LIST route's visibility gap is
  closed. 5 gateway negative tests (private chat, session-in-private-
  channel, cross-server session, manage bypass, presence stays
  membership-only).
- **OPS-001 — coturn restart loop**: the inline multi-line watcher
  collided with the image entrypoint's eval semantics (first line ran
  inside a command substitution; ~5-min restart cycles). The watcher is
  now a separate POSIX-sh script mounted read-only, invoked via an
  explicit entrypoint override.

### Fixed — high-priority (P1)

- **SEC-005 — OAuth open redirect**: protocol-relative (%2F%2Fevil) and
  backslash payloads no longer survive — sanitizeOAuthRedirect enforces
  a single-leading-slash same-origin path on BOTH the start route
  (write) and the callback (read; the cookie is client-writable
  storage). 4 fuzz-style tests.
- **SEC-004 — XFF spoofing**: nginx now sends X-Forwarded-For
  $remote_addr (NOT $proxy_add_x_forwarded_for, which preserved the
  attacker's chain); web + ws-gateway extract the LAST chain entry (the
  trusted-proxy-observed hop) instead of the attacker-controllable
  first.
- **SEC-003 — revoked sessions kept open realtime**: the WS handshake
  checks the Redis revocation set (fail-open only when Redis itself is
  down — REST stays the strict fail-closed gate); the SSE stream
  re-checks revocation on every keepalive tick.
- **OPS-002 — backup verifier trusted the manifest**: it now streams
  the REAL file's SHA-256 and stat-size and compares against the
  manifest (existence alone proved nothing). Positive + tampered-file
  tests. Also RT-001: the SSE cancel() path now runs the SAME
  idempotent cleanup as abort (interval + Redis subscription released).

## [Unreleased] - Desktop gap-analysis remediation (DP-01..DP-17) - 2026-09-02

### Fixed (desktop — see docs/DESKTOP_GAP_ANALYSIS.md)

- **DP-02 (P0)**: global push-to-talk works AFTER connecting to an
  instance. The old bridge (shell.js converting a Tauri event) died with
  the remote navigation and the remote origin has no `__TAURI__`; the
  shortcut now ALSO injects a postMessage via `window.eval` (runs on any
  page, no IPC surface opened).
- **DP-01 (P0)**: macOS ships NSMicrophoneUsageDescription /
  NSCameraUsageDescription (Info.plist file-path form — tauri-utils 2.9
  expects a PATH, not an inline map) + audio-input entitlements; without
  them getUserMedia refuses on macOS (voice = product core).
- **DP-06**: all four DEFAULT_SHORTCUTS are now registered (mute,
  deafen, settings were dead config) and forwarded to the page through
  the same eval bridge.
- **DP-05**: tray + PTT honour the persisted shell config
  (enableTray / globalPushToTalk store keys).
- **DP-16**: `disconnect_instance` actually navigates back to the local
  connect screen (was a no-op comment).
- **DP-08**: bundle.targets "all" (was msi/nsis-only — local mac/linux
  builds produced nothing while CI built them via tauri-action).
- **DP-04**: the `autoUpdate` config flag is REMOVED — no updater plugin
  exists; a visible no-op toggle lies to users.
- **DP-15**: shell CSP drops `script-src 'unsafe-inline'` (all shell
  scripts are external files).
- **DP-09**: real LobbyForge icon set (voice-arc mark over a forge bar
  on the app gradient) generated via `tauri icon` — replaces the
  template Tauri icons everywhere.

### Added

- **DP-07 — desktop session handoff (the flow, not just the parser)**:
  `POST /api/auth/desktop-session` mints a single-use code (Redis,
  5-min TTL) after credential validation; the OS-level
  `lobbyforge://session/complete` deep link is registered
  (plugins.deep-link.desktop.schemes) and the shell's `on_open_url`
  handler forwards it into the page;
  `POST .../complete` burns the code and issues the real session
  cookie. 8 route tests (mint/TTL, no-enumeration 401, burn+cookie,
  replay-401, expired-401, gone-account-401, malformed).
- **DP-17**: Windows install smoke job — downloads the NSIS artifact,
  installs silently, launches, fails if the process exits during
  startup. Installers are ALSO uploaded as run artifacts now
  (tauri-action attaches nothing without a tagName — that's why the
  first "successful" run produced zero artifacts).
- **DP-03**: README carries honest unsigned-alpha instructions
  (SmartScreen "More info → Run anyway"; macOS `xattr -cr`) until
  signing certificates exist.

## [Unreleased] - Plugin storage, two-client voice E2E, security gates - 2026-08-29
### Fixed (same batch)

- The runtime Docker image no longer ships devDependencies OR package
  managers: the Trivy CRITICAL gate caught vitest/happy-dom/tar CVEs
  (builder's full node_modules was copied wholesale), then a second
  layer — corepack's downloaded pnpm bundles its own vulnerable tar.
  The runtime now rebuilds node_modules prod-only OFFLINE from the
  builder's pnpm store, then strips the store, npm and the corepack
  cache; the web service invokes next's bin directly (no pnpm at
  runtime). Trivy: **0 findings** (CRITICAL, ignore-unfixed), verified
  against a stack running this image (both e2e suites green).


### Added

- **Plugin Storage API (Faz E)**: `ctx.storage` — persistent Postgres
  key-value storage scoped to (server, plugin); plugins never get SQL or
  a DbClient, the host executes get/set/delete/list/clear on their
  behalf. Keys constrained to [a-zA-Z0-9._:-]{1,128} (injection-shaped
  keys rejected, tested). SDK `StorageSubContext` + in-memory fallback
  in the test harness; wired through the action + activity-create
  contexts (unscoped contexts degrade to a visible in-memory fallback).
- **Two-client voice E2E (V5-008)**: two DISTINCT users (owner + a
  guest who redeems a real invite) each mint a LiveKit token, then two
  browser contexts run the actual livekit-client: connect, publish
  (fake-device) microphones, SUBSCRIBE to each other's audio track and
  round-trip data messages. Locally verified green; wired into CI. The
  e2e-ports override now runs LiveKit natively on 7890/7892 so ICE
  candidates actually reach THIS instance (the dev stack owns 7880-7882
  — advertising defaults sent client packets to the wrong container);
  the harness page is served CSP-free at the app origin via route
  interception (Chromium LNA blocks cross-origin loopback otherwise —
  test-only launch flags).
- **CI security gates (V5-009)**: CodeQL (javascript-typescript,
  security-extended) + weekly schedule, and Trivy container scan of the
  production image failing on CRITICAL (HIGH reported; base-image HIGHs
  are redeploy-actionable, not PR blockers).

## [Unreleased] - Role-gated private channels (0028) - 2026-08-29

### Added

- **Private channels (Discord-style role gating)**: a channel with role
  overrides is visible ONLY to holders of those roles (plus the owner
  and manage_channels/administrator); a channel with no overrides stays
  visible to everyone. Managed from Admin -> Channels via a role-chip
  picker ("leave empty for everyone"); gated channels show a lock badge.
- Enforcement across the surface: the channel LIST is filtered per
  member, and messages (read+write), LiveKit voice tokens, activities,
  typing, voice-mute and presence all 403 for members who cannot see
  the channel — a private room cannot be joined by guessing its id.
- `PATCH /api/servers/{id}/channels/{channelId}` accepts
  `visibleToRoleIds` ([] clears back to everyone); roles validated to
  belong to the server.
- Integration tests against a REAL migrated Postgres (override joins
  across legacy + multi-role membership, inheritance, replacement
  semantics) — wired into CI alongside the unit suites.

## [Unreleased] - Role permission upgrade: STREAM, timeouts, history/mention gates - 2026-08-17

### Added

- **STREAM permission** (role-level camera & screen-share gating): the
  LiveKit token route now intersects server-wide toggles with the
  MEMBER's role permissions — "only role X may share a screen" works.
  Without SPEAK a member joins listen-only; a timed-out member keeps
  listen access but loses the microphone.
- **MODERATE_MEMBERS timeout** — the step between a warning and a
  kick/ban: `PUT /api/servers/{id}/members/{userId}/timeout` (28-day
  cap, null clears). Enforcement on message send AND mic publish.
  Hierarchy applies (only members strictly below your highest role; the
  owner can never be timed out). Migration 0027 adds
  `memberships.timed_out_until` and backfills every existing @everyone
  role with the new baseline permissions so current servers keep their
  behaviour.
- **READ_MESSAGE_HISTORY** gate on the message list (write-only /
  announcement-style channels become possible).
- **MENTION_EVERYONE** gate on `@everyone` in message content.

### Changed

- Roles UI: new permission toggles (Timeout Members, Read Message
  History, Mention @everyone, Camera & Screen Share). `add_reactions`
  and `deafen_members` are HIDDEN from the UI until their features ship
  (no-op toggles mislead admins; enum keys stay reserved).

## [Unreleased] - Discord-style roles & hierarchy + GIF banners - 2026-08-17

### Added

- **Role hierarchy (Discord semantics)**: a member's rank is the highest
  position among their roles; the owner outranks everything. Assigning,
  editing, deleting, position-moving AND creating roles are now all
  restricted to roles STRICTLY BELOW the actor's highest — ADMINISTRATOR
  does NOT bypass this (matching Discord); only the owner does, and only
  the owner may change the owner's roles.
- **Emoji role icons**: any single Unicode emoji (ZWJ sequences, flags,
  skin tones, keycaps) is accepted alongside the legacy Material icon
  names — validated with Extended_Pictographic property escapes, length
  capped at 32 UTF-16 units; text/control-char/XSS payloads rejected.
- **GIF support**: avatars, user banners AND the new server banner accept
  animated GIF87a/GIF89a alongside PNG/JPEG/WebP.
- **Server-side image dimension enforcement**: a zero-dependency header
  parser reads real dimensions (PNG IHDR, GIF logical screen, WebP
  VP8/VP8L/VP8X, JPEG SOF scan). Avatars: min 256×256; banners: min
  960×540; both capped at 4096×4096. Polyglot defense: the declared MIME
  must match the content-sniffed format.
- **Server banner upload**: POST/DELETE /api/servers/\{id\}/banner
  (MANAGE_SERVER) persisting to servers.banner_url; the lobby sidebar
  header already renders it Discord-style behind the community name.
  Audit-logged.

### Fixed

- Git-Bash heredoc corruption trap documented in-source: shell-written
  regex files silently collapsed Unicode property escapes
  (String.raw used to make them shell-proof).

## [Unreleased] - 5th-audit fixes: production voice proxy, TURN lifecycle, fail-closed backup - 2026-08-16

### Fixed

- V5-001 (P0): production nginx forwarded `/livekit/rtc` to the upstream
  unchanged — LiveKit (serving `/rtc`, `/rtc/validate`) 404'd it, breaking
  EVERY production voice connection behind TLS. `location /livekit/` now
  pairs with `proxy_pass http://livekit:7880/;` so the prefix is stripped;
  a regression test pins the mapping and the load-bearing trailing slash.
- V5-005: LiveKit's plaintext HTTP signaling port 7880 is no longer
  published to the host in production (nginx proxies it internally at
  `wss://<domain>/livekit`); 7881 (ICE/TCP) stays public.
- V5-002: coturn now watches its certificate fingerprint and restarts
  itself when certbot renews (coturn has no reliable reload signal) —
  TURN/TLS no longer serves a stale certificate after ~60-90 days.
- V5-003: coturn denies relaying to private/CGNAT ranges (10/8,
  172.16/12, 192.168/16, 100.64/10) — with host networking a credential
  holder could otherwise pivot into the Docker/VPC network; quota raised
  (all clients share one username, 8 allocations would have capped the
  whole community); optional `LOBBYFORGE_TURN_EXTERNAL_IP` for 1:1 NAT,
  rendered into the config and reused across installer re-runs.
- V5-004: `lfctl backup restore` is FAIL-CLOSED — a missing/malformed
  checksum sidecar or a digest mismatch refuses the restore
  (`--allow-unverified` is the explicit escape hatch). The drill proves
  both refusal reasons specifically, runs on every CI push, and its
  transcript documents a full backup → DROP SCHEMA → restore → sentinel
  verification round trip.
- V5-007: idempotency claims carry a random ownership token and release
  is a Redis compare-and-delete (a stale owner can no longer delete a
  re-claimed key); a post-commit failure no longer releases the claim
  (retry reconciles via 409+GET instead of re-entering the reducer).
- V5-008: admin lazy card loading is a real tri-state — load failures
  show an error + Retry instead of masquerading as an empty pack; the
  freshly created/duplicated pack is selected immediately.
- V5-006: installer header no longer claims `curl | bash` (needs the
  repo tree); example URL points at the real repo; the update hint no
  longer suggests `docker compose pull` on a locally-built image.
- Docs (V5-010): README backup status, SECURITY.md release/backup
  wording, changelog "exactly-once" → duplicate suppression, TURN entry
  now includes TLS 5349.

## [Unreleased] - Security audit remediation + classic Taboo + E2E pipeline - 2026-08-16

### Added

- Admin word-pack management for Hushle (`/admin/plugins` + `/api/admin/card-packs`):
  create packs in any BCP-47 language, add/edit/delete words with forbidden
  words, difficulty and category; built-in packs are immutable with a
  one-click Duplicate-to-custom; admin mutations are audit-logged.
- Classic Taboo rules in Hushle: opposing-team players see the current card
  (word + forbidden words) through the canonical projector and can press
  BUST to penalise the explaining team (-1); teammates, floaters and
  anonymous buzzes are rejected server-side. The BUST button carries a
  double-click guard.
- Duplicate suppression for activity actions (LF-002 — NOT exactly-once:
  no response replay): optional `actionId` is claimed in
  Redis (SET NX + TTL) per session, never forwarded to plugin reducers,
  and released on every failure path.
- TURN fallback for restrictive networks (LF-019): standalone pinned coturn
  service (host networking, TLS certs shared with nginx), LiveKit
  `rtc.turn_servers` (udp 3478, tcp 3478 — TLS 5349 added in the 5th-audit fixes below), per-install `LOBBYFORGE_TURN_SECRET`
  generated once and reused across installer re-runs, firewall port
  checklist in the installer output, and `docs/VOICE_TURN.md` with a NAT
  test matrix.
- Compose-stack E2E (LF-023): `e2e/compose-stack.spec.ts` runs the full
  Hushle chain (setup → seeded packs → server → app install → start-game →
  projection → bust rules) against real Postgres/Redis/LiveKit and the
  production-built image; wired as a CI job plus `scripts/e2e-compose.sh`
  and an isolated parallel-stack compose override for local runs.
- CI production dependency audit gate (`pnpm audit --prod
  --audit-level=moderate`).

### Fixed

- Admin card-pack POST mutations all returned 400: the per-action Zod
  schemas were `.strict()` yet rejected their own `action` discriminator.
  Rewritten as a single discriminated union with hushle ownership enforced
  server-side, built-in immutability, ordinal-race retry and a
  language-scoped slug fallback.
- Lazy built-in pack seeding 500'd on every fresh production instance:
  runtime-only `webpackIgnore` imports could not resolve extensionless
  plugin TS imports nor the `@/lib` alias outside webpack. The seeder now
  uses plain bundled imports and the admin route triggers seeding too.
- Hushle `start-game` always 404'd: the host UI sends the pack SLUG while
  the server only looked packs up by UUID. Both are now resolved.
- Deck and current-card leakage closed for every viewer (LF-001): one
  canonical projector across GET/action/SSE, `cardsRemaining` subtracts
  used cards, the deck is removed with `delete`.
- `server_local_cards` gained a language scope (migration 0026) so
  Turkish local words never leak into German decks.
- Installer re-runs with a new domain left nginx/LiveKit on the old one
  (LF-010-R): configs are now rendered from git-tracked templates on every
  run; production images pinned (certbot v2.11.0, livekit 1.8.3,
  coturn 4.6.2); secrets are reused from an existing `.env.prod`.
- Certbot paths are fail-closed and a running stack is detected before
  standalone mode can fight nginx for port 80.
- Activity creation now registers the creator as the first player, and
  card-packs routes log errors instead of swallowing them.

## [Unreleased] - M22: Production readiness outside the app/plugin system - 2026-07-15

### Added

- Added opt-in LiveKit screen-stream subscriptions, server-enforced maximum
  stream resolution/FPS, compact voice-only tiles, rich member profiles with
  bio/roles/per-user volume, allowlisted role icons, and a single-community
  self-host lobby shell without the official instance rail.

- Adopted a Tauri 2-first desktop direction with a mandatory Windows
  LiveKit/WebView2 media and security spike; Electron remains a compatibility
  fallback until the spike passes.
- Fixed the product boundary for messaging: instance-local DMs ship first,
  while official-account friends/DMs remain a separate later service and data
  scope.
- Migrated the web app to Next.js 16.2 and React 19.2. The production
  Turbopack build completes without file-tracing warnings; no global
  `proxy.ts` was added because authentication remains enforced at route and
  server-component boundaries.
- Aligned `packages/ui` with React 19 types and React-19-compatible Lucide and
  Testing Library releases, removing the split React type graph.
- Added open and invite-only local account registration plus explicit logout.
- Added the `user_identity_links` migration and query layer. External account
  references are unique and contain no provider credentials.
- Added public-registry URL validation against insecure/private origins and a
  state-bound, one-time-code desktop session handoff parser with log redaction.
- Added full-screen voice focus, runtime keybinds, message search and
  permission-gated pinning, per-channel mute, and native desktop message
  notifications.
- Local-account password changes now verify the existing scrypt credential,
  hash the replacement, and update it with an old-hash compare-and-swap guard.
- My Account now opens the canonical password modal and persists the change
  through `/api/auth/password`; UI and API share a 12-character minimum.
- The shared API security boundary checks Redis-backed session revocations for
  authenticated cookies. Revoked cookies receive `401` and are cleared;
  production fails closed with `503` if revocation storage is unavailable.
- Password changes revoke every other tracked session while preserving the
  current device.

### Fixed

- Rebuilt the full-screen voice surface around compact voice-only tiles,
  camera grids, screen-share stage plus filmstrip, and a safe-area-aware call
  dock. Desktop and 390px Chromium checks cover camera, screen share, native
  stop-sharing, viewport bounds, and overflow.
- Made member join-date rendering deterministic (`en-US`, UTC), removing a
  server/client locale hydration mismatch in Community Settings.
- LiveKit JWT grants now serialize screen sharing as `screen_share` and
  `screen_share_audio`; the previous hyphenated values were parsed as unknown
  sources and prevented screen publication.
- Camera and screen-share UI state now follows actual LiveKit local-track
  publish/unpublish events, including the browser-native stop-sharing action.
- Rebuilt the voice focus view as a true viewport portal with a share-first
  stage, participant filmstrip, responsive camera grid, persistent media dock,
  and visible media permission errors.
- Production CSP now derives exact HTTP/WebSocket connect origins from the
  configured public LiveKit and realtime URLs. Local Docker voice traffic is
  no longer blocked before reaching LiveKit.
- Docker Compose now passes `LOBBYFORGE_SETUP_TOKEN` into the web container;
  previously the documented installer token could never satisfy production
  bootstrap validation.
- Material Symbols now ship as a local WOFF2 asset in the production image.
  Icon ligature text can no longer expand lobby/settings controls when an
  external font request is blocked; CSP no longer permits Google font hosts.
- Added a Chromium regression test for local icon loading, fixed icon width,
  hidden ligature overflow, and the absence of external Google stylesheets.
- Test-reset routes now pass through the shared API security boundary while
  retaining their test-environment and high-entropy token gates.
- Maintenance and update mutations use strict, size-bounded schemas.
- Blocked users are excluded from presence reads; public invite lookups use a
  stricter rate limit and uniform not-found behavior.
- Explicit per-server access policies are enforced by local registration
  before password hashing; approval-only modes fail closed.
- Removed settings rows that advertised unavailable 2FA, recovery, export,
  pronoun, email-digest, or mobile-push behavior.
- Initial bootstrap no longer inserts a duplicate text/voice channel pair
  after `createServer()` has already seeded the defaults.
- Added focused password and central session-revocation security tests.

### Verification

- 524 workspace tests pass; live PostgreSQL integration passes 5/5 and
  Chromium security/settings smoke tests pass 3/3.
- All 15 workspace projects pass typecheck and production build. Lint has zero
  errors and two intentional database-avatar optimization warnings.

## [Unreleased] — M21.9: Lobby interaction polish + cleanup — 2026-06-30

Final cleanup pass: dead code removal, lint warnings fixed, voice channel
visibility fix, profile popover redesign, per-user volume control, @mention
autocomplete, typing indicator, message edit/delete, text channel switching,
auto-scroll, Enter-to-send, N+1 block list fix, and IPv6/localhost connectivity
fixes.

### Added
- **Text channel switching** (`LobbyTextChannels.tsx`) — clicking a text channel switches the main area's messages. Active channel highlighted.
- **Enter-to-send** — MentionInput only intercepts Enter when the dropdown is open.
- **Auto-scroll** — LobbyLiveRoster scrolls to bottom on new messages.
- **Typing indicator** — `POST/GET /api/.../typing` + Redis TTL 5s + 3s poll + "X is typing...".
- **Message edit/delete** — hover edit/delete buttons on own messages.
- **Unread badges** — localStorage last-seen tracking, primary dot on unread channels.
- **Per-user volume slider** — `setRemoteVolume/getRemoteVolume` in LobbyVoiceProvider, popover shows slider for in-voice users, stored per-user in localStorage.
- **UserProfilePopover redesign** — 340px, banner with role color gradient, avatar overlap, bio section, volume slider, block button.
- **@mention autocomplete** — `MentionInput.tsx`, keyboard nav, role-colored suggestions.
- **BlockListProvider** — shared context, single fetch for all member rows.
- **Presence on page load** — `loadLiveData` writes the current user's presence on SSR so they appear online immediately.

### Fixed
- **Voice channels hidden** — `data-empty-channel` attribute + `hideEmptyChannels: true` default was hiding empty channels. Both removed for voice channels.
- **Profile popovers stacking** — shared `openUserId` state in LobbyMembersClient, only one popover open at a time.
- **Audio playback** — remote audio elements changed from `display:none` to `position:absolute;width:0;height:0;opacity:0` so browsers actually play them.
- **Voice channel seeding** — `createServer` now seeds `general` (text) + `Main Lounge` (voice).
- **Rules of Hooks** — LobbyMainArea split into Live + Demo variants.
- **Race condition** — `connectTokenRef` prevents orphaned rooms on rapid channel switch.
- **Render loop** — `applyRemoteAudio` effect depends on `deafenEnabled` only.
- **Cross-server IDOR** (Critical) — activities list now verifies channel ownership.
- **Raw error leaks** — admin updates + SSE stream return generic messages.
- **Role assignment IDOR** — target user verified as member before role assignment.
- **Stale closures** — `voiceChannelIds` in refs across LobbyMembersClient + LobbyVoiceChannels.
- **IPv6 connectivity** — all `.env.local` URLs changed from `localhost` to `127.0.0.1`.
- **WS Gateway** — must be started as a separate process (`node dist/index.js` from `apps/ws-gateway`).

### Removed (dead code)
- `LobbyVideoPanel.tsx` — replaced by LobbyVoiceView.
- `LobbyMemberItem.tsx` — replaced by inline MemberRow in LobbyMembersClient.
- `LobbyMessageAuthor.tsx` — replaced by plain span in LiveMessage.
- `ChannelGroupClient.tsx` — unused since LobbyTextChannels replaced it.
- Unused props: `showLabel`, `serverId` from MentionInput; `menuOpen` from LiveMessage.

### Numbers
- 326/326 tests across 51 files.
- 0 errors, 3 warnings (all `<img>` for DB-sourced avatars — pragmatic).
- 6/6 Playwright E2E tests passing.
- `/lobby` 16.1 kB First Load JS.
- 15/15 workspace projects typecheck clean.



Final lobby UX pass: text channel switching, Enter-to-send, auto-scroll,
typing indicator, message edit/delete UI, unread badges, N+1 block list
fix, audio playback fix (display:none → absolute), voice channel seeding
fix, and a comprehensive security audit with fixes.

### Added - Lobby interaction features

- **Text channel switching** (`LobbyTextChannels.tsx`) — clicking a text channel in the sidebar now switches the main area. Active channel highlighted. `LobbyVoiceProvider` holds `activeTextChannelId` + `activeTextChannelName` + `setActiveTextChannel()`. `LobbyMainArea` reads the provider's active channel and passes the correct `channelId` to `LobbyLiveRoster` (keyed remount on channel change).
- **Enter to send** — `MentionInput` only intercepts Enter when the @mention dropdown is open. When closed, Enter propagates to the form for natural submit.
- **Auto-scroll to bottom** — `LobbyLiveRoster` attaches a `scrollRef` and calls `scrollTop = scrollHeight` on every messages change.
- **Typing indicator** — `POST/GET /api/servers/{id}/channels/{channelId}/typing`. Composer sends a heartbeat every 3s while typing (rate-limited 30/10s). `LobbyLiveRoster` polls every 3s and shows "X is typing..." with animated dots. Redis TTL 5s.
- **Message edit/delete** — own messages show an edit + delete button on hover. Edit: inline `<input>` with Save/Cancel. Delete: direct `DELETE` call. Both hit the guarded message PATCH/DELETE endpoints.
- **Unread message badge** — `LobbyTextChannels` tracks last-seen per channel in localStorage. Non-active channels with recent message activity show a primary-colored dot.
- **BlockListProvider** — shared context that fetches the block list once and distributes it to all `MemberBlockButton` instances. Eliminates N+1 API calls (was 1 fetch per member row → now 1 fetch total).
- **Per-user volume slider** — `setRemoteVolume/getRemoteVolume` in `LobbyVoiceProvider`. Popover shows a volume slider for users in voice. Stored per-user in localStorage. Audio element `.volume` is set directly.
- **UserProfilePopover redesign** — Discord-style banner + avatar overlay + role color accents + block button + volume slider. Uses `useLayoutEffect` for positioning.
- **@mention autocomplete** — `MentionInput.tsx` with keyboard navigation (↑/↓/Enter/Tab/Escape), role-colored suggestions, avatar thumbnails.

### Fixed - Critical bugs

- **Audio playback** — remote audio elements used `display:none` which prevents playback in most browsers. Changed to `position:absolute;width:0;height:0;opacity:0`. Container also changed from `className="hidden"` to the same invisible-but-playable style.
- **Voice channel seeding** — `createServer()` now creates `general` (text, position 0) + `Main Lounge` (voice, position 1). `loadLiveData` auto-repairs existing servers with zero channels.
- **Rules of Hooks** — `LobbyMainArea` split into `Live` + `Demo` variants; `useLobbyVoice()` no longer called conditionally.
- **Race condition** — `connectTokenRef` prevents orphaned rooms on rapid channel switching.
- **Render loop** — `applyRemoteAudio` effect now depends on `deafenEnabled` only, not `participants`.
- **Cross-server IDOR** (Critical) — activities list GET now verifies `channel.serverId === serverId`.
- **Raw error leaks** — admin updates route + SSE stream now return generic messages.
- **Role assignment IDOR** — target user verified as server member before role assignment.
- **Block state desync** — `MemberBlockButton` checks `res.ok` before flipping state.
- **Stale closures** — `voiceChannelIds` stored in refs in `LobbyMembersClient` + `LobbyVoiceChannels`.
- **nameCacheRef stale** — `LobbyLiveRoster` syncs via `useEffect` when `knownNames` prop changes.

### Numbers

- `pnpm -F @lobbyforge/web build` green: `/lobby` ~15 kB, 90+ routes.
- `pnpm -F @lobbyforge/web typecheck` green across all 15 workspaces.
- `pnpm -F @lobbyforge/web lint` 0 errors (2 warnings: `<img>` for avatars).
- `pnpm -F @lobbyforge/web test` green: **326/326** across 51 test files.

### Security audit summary

| Finding | Severity | Status |
|---------|----------|--------|
| Cross-server IDOR (activities list) | Critical | Fixed |
| Admin updates raw error leak | High | Fixed |
| SSE stream raw error leak | Medium | Fixed |
| Role assignment IDOR | Medium | Fixed |
| Test-reset endpoints lack `withApiSecurity` | High | Fixed |
| Maintenance PATCH lacks Zod schema | Medium | Fixed |
| Admin updates POST body unvalidated | Medium | Fixed |
| Presence returns blocked users | Low | Fixed |
| Invite code length / enumeration | Low | Fixed |
| Card-packs GET has side effect | Low | Accepted (idempotent) |

## [Unreleased] — M21: Stitch designs canonical integration + security hardening — 2026-06-22

Brings the Stitch design corpus (`design_stitch/*`) into the live app as
canonical, data-driven React — replacing the static `TEMPLATE_*` lobby
mock with real DB/Redis data, landing the Calm Future motion tokens,
shipping the missing modal primitives, wiring LiveKit voice + video
into the lobby shell, **and** a parallel security hardening pass.

### Added - Real-time infrastructure (WS Presence Topic + Voice View + Block System + Session Tracking + Bandwidth)

- **WS Gateway `presence:{serverId}` topic** (`apps/ws-gateway/src/protocol.ts`, `authorize.ts`) - the gateway protocol now supports a third topic kind alongside `chat` and `activity-state`. Presence topics are 2-part (`presence:{serverId}`) and use the same server-membership authorization as the other kinds. The Redis topic is `lf:{env}:presence:{serverId}`.
- **`apps/web/lib/presence-bus.ts`** - new Redis pub/sub publisher mirroring `chat-bus.ts` and `activity-bus.ts`. The `/api/presence` POST handler calls `publishPresenceChange(...)` after writing to Redis, so every presence update (voice join/leave, heartbeat, activity change) is pushed to all WS subscribers in real time (<100ms latency).
- **`apps/web/app/lobby/LobbyMembersClient.tsx`** - real-time members panel that subscribes to `presence:{serverId}` via the `RealtimeClient` singleton for instant status updates. Falls back to a 30s HTTP poll to catch TTL expirations (laptop close, network drop). Derives member status from `channelId` membership and `lastSeen` staleness.
- **Voice View mode** (`apps/web/app/lobby/LobbyVoiceView.tsx`, `LobbyMainArea.tsx`, `LobbyVoiceProvider.tsx`) - Discord-like toggle between chat view (normal text channel, full width) and voice view (full-screen video tile grid + pinned screen-share tile + click-to-focus + "Back to Chat"). The channel header shows a "Voice View" button when connected. Video tiles no longer sit between the channel header and messages.
- **User block system** (`packages/db/src/queries/userBlocks.ts`, `drizzle/0014_user_blocks.sql`, `schema.ts` `userBlocks` table) - directional block list. Blocked users' messages are masked at the server level in both the messages API and the lobby SSR path - the content never reaches the client. Block/unblock is available from the lobby member panel (`MemberBlockButton`) and the Privacy & Activity settings page. `GET/POST /api/settings/me/blocks` + `DELETE /api/settings/me/blocks/[userId]`.
- **Session tracking** (`apps/web/lib/session-tracker.ts`) - Redis-based session fingerprints keyed by the cookie `gid`. Parses User-Agent into browser/OS/device type (zero-dependency regex). Resolves client IP via trusted proxy headers (`x-forwarded-for`, `cf-connecting-ip`). Resolves location from Cloudflare/Vercel geo headers. Fire-and-forget `recordSession(...)` on `/api/auth/guest` GET+POST and `/api/settings/me` GET. 7-day TTL. `GET/PATCH /api/settings/me/sessions` for list + revoke.
- **Bandwidth counter + alert** (`apps/web/lib/redis.ts` `incrServerBandwidth/getServerBandwidthTotals/clearBandwidthAlert`, `apps/web/app/api/admin/bandwidth/route.ts`, `apps/web/app/admin/bandwidth/page.tsx`) - LiveKit RTC stats sampled every 30s in `LobbyVoiceProvider` (outbound-rtp bytesSent + inbound-rtp bytesReceived), diffed against the previous sample, accumulated in the presence heartbeat. Per-server total/daily/hourly Redis counters with 35d/8d TTLs. Alert triggered when `LOBBYFORGE_BANDWIDTH_ALERT_BYTES` threshold is exceeded. Admin UI at `/admin/bandwidth` with 24h bar chart + per-server breakdown + acknowledge button. Health sidebar shows bandwidth summary with link.
- **Speaking indicator fix** (`globals.css`, `LobbyVoiceChannels.tsx`) - `.is-speaking` CSS no longer sets `border-color` (Tailwind handles it). Speaking avatars use `border-success` (green); non-speaking uses explicit `border-transparent`. The pulse animation only runs while the class is present.
- **Accessibility settings** (`apps/web/app/settings/accessibility/page.tsx`) - localStorage-backed local browser preferences: reduced motion (OS auto-detect + manual override via `.force-reduced-motion` class), high contrast, large text, always-show-focus. No server persistence (device-specific).
- **Community unavailable fix** (`apps/web/app/lobby/page.tsx`) - `loadLiveData` no longer returns `null` when a server has no channels. A freshly set-up server shows empty states ("No channels yet") instead of a hard error screen.

### Added - Community settings editor wiring

- **`apps/web/app/admin/settings/members/MembersClient.tsx`** - member list now supports real search, role filtering, sorting, guest badges, server nickname display, role assignment, kick, and ban actions through guarded APIs.
- **`apps/web/app/admin/settings/channels/ChannelsClient.tsx`** - channel settings now create, edit, delete, and reorder channels through the guarded channels API. The page no longer renders a read-only placeholder.
- **`apps/web/app/admin/settings/roles/RolesClient.tsx`** - role settings now create, edit, and delete roles through the guarded roles API. Permission toggles use the known core permission set, `@everyone` cannot be renamed/deleted from the UI, and administrator grants require a confirmation prompt.
- **`apps/web/app/admin/settings/voice-media/VoiceMediaClient.tsx`** - Voice & Media now persists per-server settings through `/api/servers/{id}/voice-settings`. Camera and screen-share settings are enforced in `/api/livekit/token` by narrowing LiveKit publish sources.
- **`apps/web/app/admin/settings/backups/page.tsx`** - Backups no longer shows fabricated success data. It verifies the configured backup manifest and reports missing worker/manifest state explicitly.
- **`apps/web/app/admin/settings/storage/page.tsx`** - Storage no longer invents a placeholder quota. It reports real attachment usage and clearly marks quota as not enforced until an upload gate exists.
- **`apps/web/app/admin/audit/AuditClient.tsx`** - Audit Log now has real search, category filters, sorting, metadata inspection, and client-side CSV export. Exported cells are quoted and formula-like values are neutralized to avoid spreadsheet injection.
- **`apps/web/app/admin/settings/authentication/InstanceAccessForm.tsx`** - Authentication settings now use the canonical modal styling with clear open/invite-only/closed semantics, dirty-state detection, reset, and a sticky save footer. The instance-settings PATCH endpoint now has an explicit 2 KiB body limit.
- **`apps/web/app/admin/settings/invites/InvitesClient.tsx`** - Invites now have search, active/expired/exhausted filtering, active-link copy protection, and a revoke confirmation dialog. Public invite metadata/redeem routes share a canonical 12-character code validator.
- **Build hygiene** - Removed stale lobby imports, an unused block-list import, and unused catch bindings across API routes so `next build` no longer hides real issues behind repeated unused-variable warnings.
- **Docs** - `docs/WEB_APP.md` and `projectdetails/31_PRODUCT_DECISIONS_AUTH_APPS_UPDATES.md` now describe the implemented community settings behavior, including members, roles, channels, voice/media, backups, storage, and audit review.

### Fixed - Lobby interaction and user appearance polish

- **`apps/web/app/lobby/LobbyVoiceProvider.tsx`** - remote LiveKit audio tracks are now attached to hidden audio elements, so two users in the same lobby voice channel can hear each other. Voice-activity mode starts the mic on join; push-to-talk stays muted until the configured key is held.
- **`apps/web/app/lobby/LobbyVoiceChannels.tsx`** - clicking the already-connected channel no longer disconnects the user. It opens the full voice/video view instead. Voice rosters render from the server-wide presence snapshot, so users inside voice channels are visible before the local user joins.
- **`apps/web/app/lobby/LobbyMembersClient.tsx`** and **`apps/web/app/lobby/page.tsx`** - the right member sidebar groups only Online and Offline. Voice participants remain online members instead of appearing in a separate "In Voice" section.
- **`apps/web/app/lobby/LobbyMemberItem.tsx`** and **`apps/web/app/lobby/LobbyMessageAuthor.tsx`** - member rows and chat authors now open the shared profile popover; block/unblock remains backed by the existing guarded block APIs.
- **`apps/web/app/lobby/LobbyMainArea.tsx`** - the lobby composer now posts to the guarded channel message API instead of rendering inert controls. Attachment, gift, and GIF buttons are explicitly disabled until those features land.
- **`apps/web/app/AppearanceRuntime.tsx`** and **`apps/web/app/settings/appearance/page.tsx`** - appearance settings now apply immediately and are re-applied from account/local preferences on page load. Accent, density, compact chat, hidden avatars, and empty-channel hiding have CSS hooks in `globals.css`.
- **Lobby header/server controls** - the server name hover menu now exposes admin, channel, and invite shortcuts; voice channel rows expose a channel-settings shortcut that routes to the guarded admin channel settings page.

### Milestone Status

| # | Milestone | Status | Notes |
|---|---|---|---|
| M21.1 | Lobby live data | done | `/lobby` reads channels/members/messages/presence from DB+Redis; demo mode preserved for unauthenticated visitors |
| M21.2 | Calm Future motion tokens | done | 5 keyframes + 5 `animate-*` Tailwind utilities + `.stagger-*` + `.speaking-ring` + reduced-motion override |
| M21.3 | Missing modal primitives | done | `ChangeBannerModal` + `HushleRoomSelectionModal`; `CreateVoiceRoomModal` skipped (existing `CreateChannelModal type='voice'` covers it) |
| M21.4a | Lobby → voice connect (LiveKit in lobby) | done | Sidebar voice channel click → inline `livekit-client` `Room.connect()`, real mic toggle + presence heartbeat, no `/room/[roomName]` navigation needed |
| M21.4-fix | Lobby shell full-width + identity + CSS fix | done | 72px server rail; `max-w-[1280px]` removed; identity shows `displayName` instead of UUID; nested `<main>` fixed in `/setup` and `/login`; reconnecting state in footer |
| M21.4b | Video tile grid | done | `LobbyVideoPanel` renders LiveKit participant cameras as `<video>` tiles; camera + screen share toggles in footer; speaking ring (green) + mic indicator overlay |
| M21.4c | Screen share large pinned tile + click-to-focus | done | auto-pinned large tile for screen share publishers, click-to-pin any participant, `PinnedTile` with `object-contain` for screen / `object-cover` for camera |
| M21.4d | Fullscreen / participant focus | planned | full-screen modal view of the pinned tile |
| M21.5 | Community settings live wiring | done | `admin/settings/*` pages use real DB/config data; backups report missing worker state explicitly and storage marks unenforced quotas honestly |
| M21.5-bw | Admin bandwidth counter + alert system | done | LiveKit RTC stats sampling → Redis accumulator → `/api/admin/bandwidth` → `/admin/bandwidth` page with hourly bar chart + alert threshold; Health page sidebar shows bandwidth summary |
| M21.6 | User settings completion | done | Privacy & Activity rewritten with real API + blocked users management; Accessibility localStorage-backed; Active Sessions with real Redis session tracking; non-existent features (DM/friends) removed |
| M21.6-bl | User block system | done | `user_blocks` table + query helpers + API; messages masked at server level ("🚫 Blocked user"); block/unblock from lobby members panel + settings page |
| M21.6-ses | Session tracking system | done | Redis-based session fingerprints (IP, browser, OS, device); UA parser; fire-and-forget on auth/sessions/settings routes; 7d TTL; revoke from settings |
| M21.6-lobby-polish | Lobby voice/chat/profile polish | done | remote audio attached; current channel click opens voice view; sidebars use Online/Offline only; profile popovers and composer posting wired |
| M21.7 | Hushle session view (M20c) | deferred | plugin system — user explicitly deferred to last |
| M21.S | Security hardening pass | done | dev admin bypass removed; CSRF Fetch Metadata + request size limits; crypto RNG for guest ids; raw error sanitization; mandatory test-reset token; manifest parser/fetch hardening; CSP policy strict; ws-gateway origin verification; 36/36 mutation routes wrapped with `withApiSecurity` |

### Added — Screen share large pinned tile (M21.4c)

- **`apps/web/app/lobby/LobbyVoiceProvider.tsx`** — `LobbyVoiceParticipant` gains `cameraEnabled` + `hasScreenShare` booleans so the UI can distinguish camera publishers from screen-share publishers. New `getParticipantScreenShareTrack(identity)` mirrors the camera track getter for `screen_share`-source tracks.
- **`apps/web/app/lobby/LobbyVideoPanel.tsx`** — extended with a `PinnedTile` component rendered above the participant strip when:
  - someone is sharing their screen (auto-pinned; the user cannot minimize until sharing stops), or
  - the user clicks any participant tile (manual pin; click again or click "Minimize" to return to strip-only).
  The pinned tile renders at 16:9 for screen share (`object-contain` on black) or 4:3 for camera focus (`object-cover`), with a max height of 360px so it doesn't consume the whole chat area. Each participant tile is now a `<button>` for keyboard accessibility.
- **`apps/web/app/lobby/LobbyVoiceChannels.tsx`** — the disconnected-state fallback mapping seeds the new `cameraEnabled` / `hasScreenShare` fields as `false` so TypeScript stays satisfied.

### Added — Admin bandwidth counter + alert system (M21.5-bw)

- **`apps/web/lib/redis.ts`** — three new helpers: `incrServerBandwidth(serverId, bytesDelta, {alertThresholdBytes})` accumulates into per-server total + per-day + per-hour counters (TTL: 35d / 8d) and bumps an alert key when the threshold is exceeded. `getServerBandwidthTotals(serverId, {hours})` reads the totals + trailing hourly breakdown. `clearBandwidthAlert(serverId)` clears the alert flag after admin acknowledgement. Keys: `lf:{env}:bw:{serverId}:total|{day}|{hour}` and `lf:{env}:bw:alert:{serverId}`.
- **`apps/web/app/api/presence/route.ts`** — presence POST schema gains optional `bandwidthDeltaBytes` (capped at 10 GiB to reject absurd values). When present, the handler calls `incrServerBandwidth` with the env-configured `LOBBYFORGE_BANDWIDTH_ALERT_BYTES` threshold. Bandwidth write failures are swallowed (presence write already succeeded; next heartbeat retries).
- **`apps/web/app/lobby/LobbyVoiceProvider.tsx`** — every 30 seconds, `startBandwidthSampler(room)` calls `room.getStats()` to read `outbound-rtp.bytesSent` + `inbound-rtp.bytesReceived` from the RTC stats report, diffs against the previous sample, and stashes the delta in `pendingBandwidthDeltaRef`. The existing 5-second presence heartbeat picks up the accumulated delta and sends it as `bandwidthDeltaBytes`. Disconnect + unmount tear down the sampler.
- **`apps/web/app/api/admin/bandwidth/route.ts`** — admin-only GET returns per-server totals + 24h hourly breakdown; admin-only POST acknowledges an alert. Auth: `isInstanceAdminAllowed` (owner session or `LOBBYFORGE_ADMIN_TOKEN`). Rate-limited at 30/min GET, 10/min POST.
- **`apps/web/app/admin/bandwidth/page.tsx`** — admin UI rendering: three stat cards (total / today / alert status), per-server table with a 24-hour bar chart, "Acknowledge" button per alert-triggered server. Polls every 30s. Threshold display reads `NEXT_PUBLIC_LOBBYFORGE_BANDWIDTH_ALERT_BYTES`.
- **`apps/web/app/SettingsShell.tsx`** — `COMMUNITY_NAV` gains a "Bandwidth" entry at `/admin/bandwidth` between Voice & Media and Authentication.



### Added — Security hardening (M21.S)

- **`apps/web/lib/admin-auth.ts`** — development admin bypass removed. Admin endpoints now require either an owner session cookie or a strong emergency admin token (`LOBBYFORGE_ADMIN_TOKEN`, 32+ chars, constant-time compared). The bypass that previously let unauthenticated requests through in `NODE_ENV !== 'production'` is gone — every environment applies the same gate.
- **`apps/web/lib/security-headers.ts`** — CSRF protection via Fetch Metadata (`Sec-Fetch-Site`, `Sec-Fetch-Mode`, `Sec-Fetch-Dest`) on top of the existing Origin check. State-changing browser requests without the right Fetch Metadata triples are rejected. Added a per-route request size limit (default 1 MiB; manifest upload allows more) so a malicious peer can't OOM the Node process with a giant JSON body before validation runs.
- **`apps/web/lib/password.ts`** — guest id generation moved from `Math.random()` to `crypto.randomBytes()` (Web Crypto where available, Node `crypto` fallback). Guest ids are now 256 bits of entropy, not 52-bit floats.
- **`apps/web/lib/test-reset-auth.ts`** — test-reset endpoints (`/api/test/db-reset`, `/api/test/redis-reset`) now require `LOBBYFORGE_TEST_RESET_TOKEN` unconditionally when `NODE_ENV === 'test'`. Previously the token was optional; a misconfigured CI environment without the env var would silently let the endpoint through.
- **`apps/web/lib/update-planner.ts`** — manifest fetch hardened: 5 MiB download cap, 5s connect timeout, 10s overall timeout, strict JSON schema validation, deep-freeze of the parsed object so a tampered runtime can't mutate it after validation. The parser rejects unknown top-level keys and limits nesting depth (anti-JSON-bomb).
- **`apps/web/lib/api-source-security.ts`** (new) — small helper that strips raw `Error.message` and stack traces from API responses in production. The 500 response shape is now `{error: 'Internal error', requestId}`; the detailed `detail` field is logged server-side only.
- **`apps/web/lib/lobby-mode.ts`** — lobby-mode gating helper centralizing the "is this the official deployment" + "is bootstrap complete" checks that the lobby, login, and setup pages all need. Previously each page re-implemented the conditional; now they share one helper so the security boundary is in one place.

### Tests — Security hardening

- `lib/__tests__/admin-auth.test.ts` — owner-session pass, emergency-token pass, dev bypass rejected, weak token rejected, missing token rejected.
- `lib/__tests__/security-headers.test.ts` — Fetch Metadata accept/reject matrix, request size limit enforcement.
- `lib/__tests__/test-reset-auth.test.ts` — token required in test env, token required in any env when configured.
- `lib/__tests__/security-config.test.ts` — env var presence + strength (32+ chars) for session secret, admin token, test-reset token.
- `lib/__tests__/api-source-security.test.ts` — error sanitization preserves the requestId, strips stack traces.
- `lib/__tests__/lobby-mode.test.ts` — bootstrap-complete + official-deployment matrix.
- `lib/__tests__/update-planner.test.ts` — extended with manifest-too-large, fetch-timeout, schema-rejects-unknown-keys, deep-freeze assertions.

Suite went from 273 tests / 35 files to **296 tests / 41 files** (+23 tests, +6 files).

### Added — Lobby shell full-width + server rail (M21.4-fix)

- **`apps/web/app/lobby/page.tsx`** — `LobbyShell` wraps the whole shell (server rail + sidebar + main + members panel) in `<LobbyVoiceProvider>` when `canVoiceConnect`, so both the sidebar voice channels AND the main-area video panel can read from the same LiveKit context. Removed `max-w-[1280px] mx-auto` — the lobby now fills the viewport. Sidebar uses `w-[240px] lg:w-[260px]` responsive width instead of a fixed 260. Members panel uses `w-[200px] lg:w-[230px]`.
- **`ServerRail`** (new component inside `page.tsx`) — the 72px left navigation bar from Stitch `animated_desktop_shell`: LF home button, active community pill, add-instance CTA (official deployments only), settings shortcut at the bottom. Hover tooltips via `.rail-tooltip`. Animates in with `animate-fade-in-right`.
- **`LobbyData.currentDisplayName`** (new field) — the local user's display name resolved server-side via `getUserById(db, currentUserId)`. Passed to `LobbyVoiceProvider` as `localDisplayName` and seeded into `buildKnownNames()` so the LiveKit voice roster shows the user's real name on the local tile instead of the raw UUID identity (which the token endpoint sets to `session.uid`).
- **Token endpoint now receives `displayName`** — `LobbyVoiceProvider.connectToChannel` sends `{serverId, channelId, displayName: localDisplayName}` so LiveKit's participant `name` field is the human name, not "Guest" or empty.
- **Connection state UI feedback** — `LobbyVoiceFooter` distinguishes Connecting / Reconnecting / Connected / Ready with the right dot color and label. The mic / camera / call-end buttons are disabled until Connected, not just until `voice.connecting` flips false.
- **CSS layout fix** — `/setup` and `/login` had a nested `<main>` inside the layout's outer `<main className="flex-1">`, plus `min-h-dvh` on the inner element that fought the outer flex. Both pages now use `<div className="flex h-full w-full items-center justify-center bg-background px-5 py-10 safe-area-page">`. The HTML semantic violation (nested `<main>`) is fixed too. Tailwind stack is `^3.4.13` (v3, not v4) — confirmed against `apps/web/package.json`. The whole Calm Future token system (`tailwind.config.ts` colors/font/spacing/animation) is v3-idiomatic and does not require a v4 migration.

### Added — Video tile grid (M21.4b)

- **`apps/web/app/lobby/LobbyVoiceProvider.tsx`** — extended with `cameraEnabled`, `screenShareEnabled`, `toggleCamera`, `toggleScreenShare`, `getParticipantCameraTrack(identity)`. Camera toggle calls `localParticipant.setCameraEnabled`; screen share calls `setScreenShareEnabled`. `getParticipantCameraTrack` walks the local + remote participant publications for a `camera`-source video track and returns the underlying `MediaStreamTrack` — the tile attaches it directly to a `<video>` element without pulling in `@livekit/components-react`.
- **`apps/web/app/lobby/LobbyVideoPanel.tsx`** — client island rendered above the message list when connected. Horizontal scroll strip of `ParticipantTile`s (160×100 each); each tile shows either the camera feed (live `<video>` with `srcObject = new MediaStream([track])`) or a fallback avatar with the participant's initial. Speaking participants get the green `.speaking-ring`; muted participants get a `mic_off` overlay; the local tile is `muted` so we don't echo. The panel only renders when `connectionState === Connected && activeChannelId`, so an idle lobby sees nothing.
- **`apps/web/app/lobby/LobbyVoiceFooter.tsx`** — camera + screen share buttons are now functional. When on, they get the brand-color fill (camera → primary, screen share → tertiary) so the user can see their state at a glance. Disabled state unchanged (no connection = no toggle).
- **`apps/web/app/lobby/page.tsx`** — `LobbyShell` wraps everything in `<LobbyVoiceProvider>` so both Sidebar (voice channel list + footer) and MainArea (video panel) read from the same context. `MainArea` takes a new `showVideoPanel` prop and renders `<LobbyVideoPanel />` between the channel header and the message list. Provider cleanup resets camera/screen-share state on disconnect.

### Added — User settings completion (M21.6)

- **`apps/web/app/settings/privacy-safety/page.tsx`** — DM permissions (everyone / server_members / friends), friend requests toggle, DM image scanning, explicit content filter, presence visibility (same three tiers), blocked-users entry point, data export button. Pattern mirrors the existing settings pages (`Section` + `Toggle` + radio cards). Backend persistence is intentionally stubbed (Save button disabled with tooltip) until `/api/settings/me` gains a `safety` field.
- **`apps/web/app/settings/accessibility/page.tsx`** — reduced motion (auto / on / off, with `prefers-reduced-motion` detection), high contrast (same tri-state + `prefers-contrast: more` detection), live captions toggle + caption size, keyboard focus sticky toggle, sticky channel/server headers, "disable flashing animations" toggle that targets the speaking ring + presence dots. Includes a WCAG 2.2 AA note linking to the W3C guidelines.
- **`apps/web/app/settings/active-sessions/page.tsx`** — current session + other sessions list (empty today until `/api/settings/me/sessions` lands; we deliberately render an empty state instead of fake data), per-session revoke button, and a "Sign out everywhere" danger zone with a typed `SIGN OUT` confirmation gate. Pattern matches the existing `danger` tone used by `MyAccountBody`.
- **`apps/web/app/SettingsShell.tsx`** — `USER_NAV` gains three entries: Accessibility, Privacy & Safety, Active Sessions. Existing "Privacy & Activity" stays at `/settings`.



### Security hardening

- Made first-run setup irreversible with `bootstrap_version = 2`, a
  PostgreSQL advisory transaction lock, production setup token enforcement,
  and real concurrent-database coverage.
- Added strict setup text validation, sanitized error logging, a global CSP,
  HSTS and browser hardening headers.
- Removed self-host production fallback to lobby demo data on DB failure.
- Updated Drizzle ORM to 0.45.2 and pinned patched PostCSS/esbuild transitive
  dependencies. The audit service reported no known vulnerabilities at that
  milestone; see the current verification snapshot for today's audit status.
- Removed the development-wide admin bypass. Instance administration now
  requires the locked setup owner session or a constant-time emergency token.
- Switched guest IDs to cryptographic randomness, removed raw API error details,
  added mutation body limits and cross-site Fetch Metadata rejection, and made
  test reset tokens mandatory.
- Strictly validates update manifest fields and commands; production remote
  manifests require HTTPS and enforce redirect, timeout, and response-size
  limits.

### Fixed - Lobby voice connection

- Repaired the lobby heartbeat URL: the client now posts `serverId`,
  `channelId`, and status to `/api/presence` instead of POSTing to the
  read-only channel roster endpoint.
- Voice roster labels now prefer the signed LiveKit participant `name`, so the
  local nickname appears immediately after connection.
- Removed an unsupported `Room.getStats()` sampler that failed typecheck and
  could never collect bandwidth in the installed LiveKit client.
- Added deterministic disconnect cleanup and fixed a stray brace in the
  presence route that had been hidden by the stale dev bundle.
- Local Docker LiveKit now advertises `127.0.0.1` as its ICE node address;
  this prevents same-machine clients from receiving unreachable Docker bridge
  or public NAT candidates.

### Fixed - Security audit follow-up

- Derived LiveKit moderation room names server-side from `serverId` +
  `channelId`; the voice mute endpoint now validates operator membership,
  target membership, channel ownership/type and `MUTE_MEMBERS` before touching
  LiveKit.
- Tightened the shared API Origin guard: production mutation requests now
  require a valid first-party Origin even when Fetch Metadata is absent.
- Hardened the WebSocket gateway with production Origin allowlisting, required
  production `REDIS_URL`, and real `@lobbyforge/db.createDb` wiring for
  membership checks.
- Fixed chat/activity Redis subscriber lifetime so one closing listener does
  not tear down other listeners on the same topic.
- Removed raw stdout/stderr chunks from update command execution events;
  bounded output is still returned to the caller, while persisted event
  metadata stores only byte counts/truncation status.
- Fixed the lobby "Community unavailable" regression after user block-list
  support: exported `userBlocks` queries from `@lobbyforge/db`, journaled and
  applied the `0014_user_blocks` migration, and added a source regression test
  for the lobby data export.

### Added - Community settings invites

- `/admin/settings/invites` now has a live create/copy/revoke UI backed by
  the existing invite APIs. Admins can choose max uses and expiry, search,
  filter by status, copy active `/join/{code}` links, and revoke links through
  a confirmation dialog.
- Invite revoke now uses the same `CREATE_INVITE` permission boundary as invite
  creation, instead of the unrelated role-management permission.

### Added — Lobby → voice connect (M21.4a)

- **`apps/web/app/lobby/LobbyVoiceProvider.tsx`** — React context provider that owns a single `livekit-client` `Room`. Exposes `connectToChannel(channelId)` / `disconnect()` / `toggleMic()` plus the render state (`activeChannelId`, `connectionState`, `connecting`, `error`, `micEnabled`, `participants`). Mirrors the M14 `/room/[roomName]` flow: `/api/auth/guest` rebind → `POST /api/livekit/token {serverId, channelId}` → `new Room({adaptiveStream, dynacast}).connect(livekitUrl, jwt)` → publish mic muted-by-default → 5s presence heartbeat. Listens to `ConnectionStateChanged`, `ParticipantConnected/Disconnected`, `ActiveSpeakersChanged`, `TrackMuted/Unmuted`, `LocalTrackPublished/Unpublished` so the sidebar voice roster shows real speaking indicators and mute state. Tears down the room + heartbeat on unmount.
- **`apps/web/app/lobby/LobbyVoiceChannels.tsx`** — Sidebar voice-channel list as a client island. Same visual classes as the M19 `ChannelGroup` (voice group), but each channel row is a button that calls `connectToChannel(c.id)`. When connected, the roster reads from the LiveKit participants (real speaking ring + `mic_off`); when not connected, falls back to the SSR presence snapshot from `data.voiceUsers`. Clicking the active channel disconnects. Shows `connecting…` and `connected` badges inline.
- **`apps/web/app/lobby/LobbyVoiceFooter.tsx`** — Voice control footer wired to real state. The "Voice Connected" / "Voice Ready" label, the call-end button (disconnects), and the mic toggle are all functional. The video camera and screen share buttons render but are disabled with "coming in M21.4b/c" tooltips. The deafen button is disabled (M21.4 follow-up).
- **`apps/web/app/lobby/page.tsx`** — Sidebar now branches on `data.isLive && data.serverId && hasUser`. The live path wraps the channels list + footer in `<LobbyVoiceProvider>`, renders `<LobbyVoiceChannels>` instead of the SSR `ChannelGroup` for the voice group, and renders `<LobbyVoiceFooter>` instead of the legacy `<VoiceControlFooter>`. The demo path (unauthenticated / official deployment) keeps the original SSR-only rendering — no LiveKit bundle is loaded for the demo view. A small `buildKnownNames(data)` helper seeds the participant identity → display name lookup from the SSR snapshot.
- **CSS layout fix** — `/setup` and `/login` had a nested `<main>` inside the layout's outer `<main className="flex-1">`, plus `min-h-dvh` on the inner element that fought the outer flex. Both pages now use `<div className="flex h-full w-full items-center justify-center bg-background px-5 py-10 safe-area-page">`. The HTML semantic violation (nested `<main>`) is fixed too. Tailwind stack is `^3.4.13` (v3, not v4) — confirmed against `apps/web/package.json`. The whole Calm Future token system (`tailwind.config.ts` colors/font/spacing/animation) is v3-idiomatic and does not require a v4 migration.

### Added — Lobby live data (M21.1)

- **`apps/web/app/lobby/page.tsx`** — rewrites the standalone lobby page to fetch real data when the visitor is authenticated and has a server:
  - Sidebar channels come from `listChannelsForServer(db, serverId)`, grouped by `type` into Text Channels / Voice Channels (the `voice` + `stage` types collapse into the voice group).
  - The active voice channel's roster comes from `getUserPresenceInChannel(channelId)` (Redis presence).
  - The right-hand members panel comes from `listMemberSummariesForServer` + `getUserPresenceInServer`; member status is derived (`in-voice` when `channelId` is one of the voice channels, `online` for any other presence, `offline` otherwise) and sorted by status then name.
  - The chat area comes from `listMessagesForChannel(activeTextChannel.id, {limit:50})`; author display names are resolved with a parallel `getUserById` lookup per unique author id and threaded into a `Record<userId, displayName>` map.
  - Demo mode preserved verbatim for unauthenticated visitors and for the official deployment — the original `DEMO_*` constants render the Stitch `refined_standalone_lobby_1280px` reference without any server round-trip.
- **`apps/web/app/lobby/LobbyLiveRoster.tsx`** — client island rendered only when the lobby has live data. Subscribes to `chat:{serverId}:{channelId}` via the existing `RealtimeClient` singleton and prepends new messages into local state (de-duped by message id so the SSR snapshot and the first WS event don't double-render). Polls `/api/servers/{id}/channels/{channelId}/presence` every 8s to keep the author name cache warm. Same visual classes as the SSR Message component — design is 1:1.
- **Empty states**: a server with no channels renders an empty channel list rather than the demo; a server with no messages renders "No messages yet — be the first to say something." in the chat area.

### Added — Calm Future motion tokens (M21.2)

- **`apps/web/tailwind.config.ts`** — five canonical keyframes (`fadeInRight`, `fadeInLeft`, `fadeInUp`, `pulseSoft`, `speakingPulse`) and five matching `animate-*` Tailwind utilities, sourced 1:1 from `design_stitch/lobbyforge_animated_desktop_shell/code.html`. The `speakingPulse` uses the green `#7CCFA6` token (LiveKit video-tile convention); the legacy `.is-speaking` ice-blue avatar pulse from earlier milestones is preserved unchanged.
- **`apps/web/app/globals.css`** — the same five keyframes are mirrored as raw CSS for non-Tailwind consumers, plus six stagger delay utilities (`.stagger-1..6` = `0.1..0.6s`), `.speaking-ring` for video tiles, `.rail-tooltip` for the 72px server rail hover pattern, and a `@media (prefers-reduced-motion: reduce)` override that disables every animation utility + the special classes. The override matches the Stitch accessibility intent verbatim.
- **Applied** to the lobby: Sidebar `animate-fade-in-right`, MembersPanel `animate-fade-in-left`, MainArea `animate-fade-in-up`, channel groups carry `stagger-1` / `stagger-2`, each new WS chat message in `LobbyLiveRoster` enters with `animate-fade-in-up`.

### Added — Missing modal primitives (M21.3)

- **`apps/web/components/modals/ChangeBannerModal.tsx`** — wide-image (3:1, 1200×400 canonical crop) sibling of `ChangeAvatarModal`. Same canonical flow: file pick → live preview with zoom/rotate → canvas crop on save. 8 MB cap. Stitch reference folder (`design_stitch/lobbyforge_change_banner_modal_overlay/`) is empty so the visual treatment follows the existing avatar modal + the lobby sidebar banner area.
- **`apps/web/components/modals/HushleRoomSelectionModal.tsx`** — voice-channel picker shown when a host starts Hushle in a server with more than one voice channel. Pre-selects the channel the host is currently in, filters out channels with an active activity (one-activity-per-channel mutex), and exposes `busy` per channel so the caller can mark "Session in progress" rooms. `HushleHowToPlayModal` pattern + `play_arrow` PrimaryButton.
- **`CreateVoiceRoomModal`** — intentionally skipped. The existing `CreateChannelModal` already handles voice room creation via its `type='voice'` branch (used today by `apps/web/app/lobby/ChannelGroupClient.tsx`). The Stitch `lobbyforge_create_voice_room_modal_state/code.html` is the full lobby shell + overlay state, not a separate modal design.

### Numbers

- `pnpm -F @lobbyforge/web build` green: `/lobby` 6.71 kB, `/admin/bandwidth` 4.01 kB. All 15 workspace projects typecheck clean.
- `pnpm -F @lobbyforge/web test` green: **302/302** across 42 test files (+6 tests, +1 file vs M21.6 — the security-headers test grew to cover Fetch Metadata + request size + production Origin guard).
- `pnpm -F @lobbyforge/web lint` green (0 errors, 63 warnings — all pre-existing `any` / unused-var noise in API routes).
- **Security audit**: 36/36 mutation routes wrapped with `withApiSecurity`. 0 `innerHTML` / `dangerouslySetInnerHTML` / JS `eval()`. CSP: `frame-ancestors 'none'`, `object-src 'none'`, `base-uri 'self'`, HSTS in prod, `Permissions-Policy: camera=(self), microphone=(self)`. No secret env vars exposed as `NEXT_PUBLIC_*`.

### Key decisions

- **Live data path is opt-in via "user has a server".** The unauthenticated lobby view is the demo — it preserves the original Stitch reference rendering exactly. The moment `listServersForUser` returns a row, the page switches to live data. This keeps the lobby useful both as a marketing surface (official deployment) and as a real community shell (self-host).
- **Chat realtime lives in a single client island; voice/video share one provider.** `LobbyLiveRoster` handles the chat WS subscription. `LobbyVoiceProvider` wraps the entire lobby shell (server rail + sidebar + main + members panel) so the sidebar voice channels, the voice footer, AND the main-area video panel all read from the same LiveKit `Room`. One connection, three consumers.
- **Demo mode is not "fake data hiding real data"** — it's an explicit mode. `data.isLive === false` renders the DEMO_* constants verbatim; `data.isLive === true` renders the client islands. The LiveKit bundle is NOT loaded in demo mode (the provider is only mounted when `canVoiceConnect === true`).
- **Animation tokens are duplicated** in `tailwind.config.ts` (for Tailwind consumers) and `globals.css` (for raw CSS consumers like `.stagger-*` + `.speaking-ring`). The `@keyframes` blocks must stay in sync; a `// keep in sync` comment in both files flags this.
- **Tailwind v3.4.13 stays.** v4's CSS-first config would require rewriting the entire `tailwind.config.ts` (90+ Calm Future tokens), and the @tailwindcss/forms + container-queries plugins are still settling on v4 compatibility. Migration risk > benefit