# 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 (`.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-`. - **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::rate-limit:upload-bytes:`). 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`, 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 `