docs/CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# 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