docs/WEB_APP.md
Rama main · réplica de lectura del repositorio original.
# `apps/web` — Next.js 15 App Router
`apps/web` is the main LobbyForge web application. It is a Next.js 15 App Router project that ships the public landing page, the admin System Health view, the guest auth + LiveKit token flow, the Phase 2 servers + channels + messages + roles API, and the M16 activity host (the plugin SDK's HTTP runtime).
This document complements [`docs/MONOREPO.md`](./MONOREPO.md) (which covers the workspace), [`docs/DOCTOR.md`](./DOCTOR.md) (which covers the Doctor subsystem), [`docs/GUEST_AUTH.md`](./GUEST_AUTH.md) (cookie format + Phase 1 endpoints), [`docs/SERVERS.md`](./SERVERS.md) (the Phase 2 server endpoints), [`docs/CHANNELS.md`](./CHANNELS.md) (the Phase 2 channel endpoints), [`docs/MESSAGES.md`](./MESSAGES.md) (the Phase 2 message endpoints), [`docs/ROLES.md`](./ROLES.md) (the Phase 2 roles + permissions surface), [`docs/ACTIVITIES.md`](./ACTIVITIES.md) (the M16 plugin host), [`docs/PLUGIN_SDK.md`](./PLUGIN_SDK.md) (the plugin contract), and [`docs/UPDATES.md`](./UPDATES.md) (the self-host update planner).
## Stack
| Concern | Choice | Notes |
|---|---|---|
| Framework | Next.js 15.5 (App Router) | React Server Components, server actions, `typedRoutes` |
| UI primitives | `@lobbyforge/ui` | Local package, transpiled by Next via `transpilePackages` |
| i18n | `apps/web/lib/i18n` | Catalogues in `apps/web/messages/<code>/`, discovered from disk; `useT()` in client components, `getTranslator()` in server ones. See [TRANSLATING.md](TRANSLATING.md) |
| Domain | `@lobbyforge/core` | Health + Doctor + roles + permissions + validation |
| Data | `@lobbyforge/db` | Drizzle 0.45.2 + `postgres.js`; thin query helpers keep values parameterized and out of SQL structure |
| Styling | Inline CSS (this pass) | Tailwind is in `@lobbyforge/ui` and will be wired in once a design system lands |
| Linting | Root ESLint flat config + app-local re-export | `@next/eslint-plugin-next` runs for App Router files alongside `typescript-eslint` |
| Tests | Vitest (server-only) | Component tests will be added with `@testing-library/react` once UI islands exist |
## Layout
```
apps/web/
├── next.config.mjs # transpilePackages, typedRoutes
├── tsconfig.json # extends @lobbyforge/config/tsconfig.base.json
│ # (overrides moduleResolution to "Bundler" — see below)
├── vitest.config.ts # @/ path alias, includes lib/__tests__/**
│ # and app/api/**/__tests__/**
├── package.json
└── src/
├── index.ts # APP_NAME, APP_VERSION, ROUTES, findRouteByPath
└── __tests__/
└── web.test.ts # smoke tests for the metadata exports
└── app/
├── layout.tsx # <html>, header, nav
├── page.tsx # home — lists ROUTES from @lobbyforge/web
├── connect/page.tsx # guest + LiveKit-token demo (developer surface since M14)
├── join/[code]/page.tsx # invite-redeem landing (M14)
├── room/[roomName]/page.tsx # voice-room UI, livekit-client (M14)
├── admin/health/page.tsx # server component, renders DoctorReport
└── api/
├── health/route.ts # GET, buildHealthStatus, 120 req/min
├── doctor/route.ts # GET, collectDoctorReport, 12 req/min
├── auth/guest/route.ts # POST mints a guest, GET probes the lf_guest cookie
├── livekit/token/route.ts # POST exchanges the cookie for a LiveKit JWT
├── plugins/route.ts # GET compiled-in app catalog summaries
├── presence/route.ts # POST + GET ?serverId=…, Redis-backed presence
├── test/db-reset/route.ts # POST truncates DB (test-only, NODE_ENV guard)
├── test/redis-reset/route.ts # POST flushes Redis (test-only, NODE_ENV guard)
├── invites/[code]/route.ts # GET public metadata (M14)
├── invites/[code]/redeem/route.ts # POST redeem, 30 req/min (M14)
└── servers/
├── route.ts # GET list, POST create, 60/10 req/min
├── [id]/route.ts # GET single (member-or-owner since M11), 60 req/min
├── [id]/access-policy/route.ts # GET/PATCH server access + auth policy
├── [id]/apps/route.ts # GET/POST/DELETE installed apps for this server
├── [id]/bots/route.ts # GET server bots + UI badge metadata
├── [id]/channels/
│ ├── route.ts # GET list, POST create, 60/10 req/min
│ ├── [channelId]/route.ts # GET / PATCH / DELETE single, 60/30/10 req/min
│ ├── [channelId]/messages/
│ │ ├── route.ts # GET list, POST create, 60/30 req/min
│ │ ├── [messageId]/route.ts # GET / PATCH / DELETE single, 60/30/10 req/min
│ │ └── __tests__/messages.test.ts
│ ├── [channelId]/presence/route.ts # GET channel-scoped presence (M14)
│ └── __tests__/channels.test.ts
├── [id]/invites/
│ ├── route.ts # GET list, POST create, 60/10 req/min (M14)
│ └── [inviteId]/route.ts # DELETE revoke, 10 req/min (M14)
├── [id]/roles/
│ ├── route.ts # GET list, POST create, 60/10 req/min (M13)
│ ├── [roleId]/route.ts # GET / PATCH / DELETE single, 60/30/10 req/min (M13)
│ └── __tests__/roles.test.ts # 15 tests (M14)
└── [id]/members/
├── route.ts # GET list, 60 req/min (M13)
├── [userId]/route.ts # DELETE kick, 20 req/min (M13)
├── [userId]/role/route.ts # PUT assign role, 20 req/min (M13)
└── __tests__/members.test.ts # 13 tests (M14)
└── __tests__/servers.test.ts
└── lib/
├── doctor.ts # collectSystemStats, collectDoctorReport, buildChecksFromStats
├── security-headers.ts # withApiSecurity, inMemoryRateLimit (now generic over TContext)
├── cookies.ts # HMAC-signed cookie helpers (lf_guest wire format)
├── guest-session.ts # createGuestIdentity, build/read guest session cookies (uid in payload)
├── livekit.ts # issueLiveKitToken (jose-backed HS256 JWT)
├── permissions.ts # authorizeServerPermission (M13)
├── db.ts # getDb() singleton (globalThis-stashed) + __setDbForTests hook
├── redis.ts # ioredis singleton + set/get user presence (server + channel)
└── __tests__/
├── doctor.test.ts
├── security-headers.test.ts
├── cookies.test.ts
├── guest-session.test.ts
├── livekit.test.ts
└── db.test.ts
```
See [`docs/GUEST_AUTH.md`](./GUEST_AUTH.md) for the cookie format, the auth endpoints, the JWT shape, and the `/connect` flow. See [`docs/SERVERS.md`](./SERVERS.md) for the servers API and the DB wiring. See [`docs/CHANNELS.md`](./CHANNELS.md) for the channels API and the membership check. See [`docs/MESSAGES.md`](./MESSAGES.md) for the messages API. See [`docs/ROLES.md`](./ROLES.md) for the roles + permissions surface.
## Why we override `moduleResolution: "Bundler"` here
`packages/config/tsconfig.base.json` sets `module: "NodeNext"` + `moduleResolution: "NodeNext"`, which is correct for *libraries* — it forces explicit `.js` suffixes on relative imports and matches the way Node ESM resolves at runtime.
For the **web app** we override those two to `"ESNext"` + `"Bundler"` so:
- The Next.js App Router can resolve `next/server`, `next/navigation`, etc. cleanly.
- The `paths: { "@/*": ["./*"] }` alias works without the `.js` suffix dance.
- The bundled output (Webpack/Turbopack) handles the resolution at build time.
The override is scoped to `apps/web` only — the packages keep the stricter NodeNext contract. This is documented in `tsconfig.json` itself as a comment so the next reader doesn't undo it.
## Why `@lobbyforge/db` is in `serverExternalPackages`
`@lobbyforge/db` is consumed as a runtime `require()` (not bundled). Its query helpers lean on `postgres.js`, which uses `node:net` internally — exactly the kind of Node-only import that explodes in the edge runtime with `UnhandledSchemeError: Reading from "node:crypto" is not handled by plugins`.
`next.config.mjs` declares:
```js
serverExternalPackages: ['@lobbyforge/db'],
```
This tells Next to leave `@lobbyforge/db` as a runtime `require()` instead of bundling it into the edge build. The main entry has no `node:` built-in imports, so this is defense-in-depth — but it keeps a future regression cheap to fix (adding a `node:` import to a query helper will not blow up the build).
## Database migrations
LobbyForge uses two migration layers with different trust boundaries:
- `drizzle-orm/postgres-js/migrator` transitively imports `node:crypto` + `node:path` + `node:url`.
- The `postgres` driver adds `net` + `tls` + `stream` to that list.
- Webpack's edge-bundle resolver cannot follow any of these `node:*` imports, even with `serverExternalPackages: ['drizzle-orm', 'postgres']` set, because the dynamic `await import('postgres')` inside `db-migrate.ts` is still treated as a chunk dependency that needs to resolve at build time.
- The `Module not found: Can't resolve 'net'` error surfaces from `postgres/src/connection.js:1` no matter where the migrator lives (workspace package, subpath export, apps/web local file).
Host schema migrations are a **pre-start deployment step**. They use committed,
journaled SQL and must succeed before the new web process starts:
```sh
# after building @lobbyforge/db, before every production start:
pnpm -F @lobbyforge/db db:migrate
```
Trusted plugin/game/bot/tool **data migrations** run on their first server-side
use after the schema is current. They deliberately do not run from Next
instrumentation because its development webpack target cannot bundle
postgres.js and Node net/tls/crypto modules. The `component_migrations` ledger
records component type, stable id, contiguous
version, SHA-256 checksum and applied time. Each step runs with a PostgreSQL
transaction advisory lock; a failed step is not marked applied, and changing an
already-applied migration fails on checksum mismatch. Community packages never
receive arbitrary SQL execution through this mechanism.
## ESLint
The web app uses the monorepo root `eslint.config.js`. `apps/web/eslint.config.js`
is a tiny re-export so Next-aware linting works whether the command is launched
from the repo root or from `apps/web`.
The web-specific config enables `@next/eslint-plugin-next` recommended and
Core Web Vitals rules with `settings.next.rootDir = "apps/web/"`. The rule
`@next/next/no-html-link-for-pages` is disabled because this app is App Router
only; page-route discovery for the legacy Pages Router would be noise here.
## Security posture
`lib/security-headers.ts` provides a small `withApiSecurity(handler, options)` wrapper used by every route. As of M10 it is generic over a `TContext` argument so dynamic route segments (`[id]`, future `[slug]`, etc.) can pass the Next.js 15 `ctx` through. It applies:
- The four standard security headers (see `DOCTOR.md` for the list).
- A 405 + `Allow` header for any method not in the allowlist.
- An atomic Redis fixed-window rate limit in production, configurable per route.
- Redis-backed session revocation for authenticated cookies. Revoked sessions
are rejected centrally before route handlers run. Login, guest sign-in and
first-run setup explicitly bypass this check so a revoked browser can recover.
Local owner credentials are created atomically by `/api/setup/complete` using
scrypt. `/api/auth/login` uses a dummy-hash verification for unknown accounts
to reduce timing-based account discovery. `/api/auth/password` verifies the
current credential, performs an old-hash compare-and-swap update, and revokes
other tracked sessions.
Bot protection (sign-up, new guests and suspicious sign-ins solve a
challenge — the built-in ALTCHA by default, Turnstile or reCAPTCHA
optionally) is described in [CAPTCHA.md](./CAPTCHA.md).
What is **not** there yet:
- **Synchronizer CSRF tokens.** State-changing browser requests already enforce
same-origin `Origin` checks and session cookies use `SameSite=Lax`; explicit
CSRF tokens remain planned for cross-origin desktop handoff flows.
- **Proxy configuration.** Client IP headers are ignored unless
`LOBBYFORGE_TRUSTED_PROXY=x-forwarded-for|cloudflare` is explicitly set for a
trusted proxy that strips and replaces that header. Without it, production
puts every client in one rate-limit bucket: the app still starts, logs a
warning once per process and Doctor reports a `trusted_proxy` warning.
Behind the bundled nginx use `x-forwarded-for` (with
`infra/nginx/conf.d/cf-real-ip.conf` when Cloudflare is in front).
`cloudflare` (the app reads `CF-Connecting-IP` itself) is only safe when the
origin accepts traffic from Cloudflare alone — see
[DEPLOY_CLOUDFLARE.md](./DEPLOY_CLOUDFLARE.md#which-lobbyforge_trusted_proxy-to-use).
The implementation is a self-hosted, no-Supabase adaptation of the `secure-nextjs-api-routes` skill in `.agents/`. We did not import that skill's `lib/csrf-protection.ts` / `lib/rate-limiter.ts` verbatim because they pull in Supabase; the patterns were re-implemented to match the LobbyForge stack.
### Current Security Hardening
The latest security pass added these concrete guards:
- A global CSP denies objects and being framed, frames only YouTube's
privacy-enhanced player (`frame-src https://www.youtube-nocookie.com`, for
Watch Party — see [WATCH_PARTY.md](./WATCH_PARTY.md)), restricts
forms/assets/connections, omits `unsafe-eval` in production, and upgrades
insecure production requests.
- HSTS, `nosniff`, `DENY`, strict referrer policy, and camera/microphone
permissions are emitted for every route.
- Production dependencies are pinned to patched Drizzle/PostCSS/esbuild
versions. On 2026-07-16 both pnpm 10.12.1 and 11.13.0 audit clients received
HTTP 410 from npm's retired legacy audit endpoint, so this pass does not
claim a successful advisory result.
- `/api/livekit/token` requires `serverId`, `channelId`, local `uid`, server
membership, voice/stage channel type, and `CONNECT_VOICE`.
- `/api/presence` validates membership and channel ownership before writing.
- Activity start rejects a second active activity in the same voice/stage
channel.
- Activity start requires the selected plugin to be installed and enabled for
the server.
- `/api/servers/{id}/apps` gates app install/configure/delete behind
`MANAGE_SERVER`.
- `/api/servers/{id}/access-policy` stores the server's join policy,
official LobbyForge identity behavior, local-account policy, and
account-linking mode. Updates require `MANAGE_SERVER`.
- Local registration honors an explicitly persisted server access policy
before password hashing. Invite-only, local-account-disabled, and approval
policies cannot be bypassed through the instance registration endpoint.
- `/api/servers/{id}/bots` lists server bots for members. The Server
`bots` tab shows `BOT`, trust, enabled/disabled, token status, and
declared permissions.
- Activity actions are gated by plugin `actionPolicies`; missing policies are
host-only by default.
- `withApiSecurity` rate limits by route + caller IP and rejects invalid
`Origin` headers on state-changing browser requests.
- `/api/doctor` and `/admin/health` require `LOBBYFORGE_ADMIN_TOKEN` in
production.
- User-created message metadata cannot use reserved system/plugin keys.
- `/api/test/db-reset` and `/api/test/redis-reset` remain `NODE_ENV=test`
only. When `LOBBYFORGE_TEST_RESET_TOKEN` is set, callers must also send
`x-lobbyforge-test-token`.
## Settings
## First-run bootstrap
- `/setup` collects the instance name and owner display name, email, and password.
- A PostgreSQL transaction and advisory lock create or repair the owner, first
server, membership, roles, default channels, and setup state atomically.
- Completion writes `bootstrap_version = 2`. This is an irreversible security
lock: deleting or corrupting the owner/server does not expose `/setup` again.
Recovery must happen through authenticated administration or the database.
- Concurrent completion requests are serialized by a per-instance PostgreSQL
advisory lock; an integration test verifies exactly one request succeeds.
- Legacy installations with an owner but no credentials/server return to
`/setup`; the existing owner UUID is retained and completed in place.
- Successful setup issues the owner session. Later visits use `/api/auth/login`.
- Production requires `LOBBYFORGE_SETUP_TOKEN`; `pnpm lfctl setup token` creates
a cryptographically random token without writing it to disk.
- Visible setup fields reject HTML delimiters and control characters. Drizzle
parameterizes accepted values, including SQL-looking text.
### Canonical settings surface
Settings routes render as one full-viewport modal surface above the lobby.
`settings/layout.tsx` owns the user `SettingsShell`. Admin pages draw their
own community shell, after their guard (see below), so a refused page never
shows its 404 inside admin chrome; a compatibility guard keeps a nested
shell from creating a second dialog. Server settings use the same
`SettingsModalFrame` around their server-specific navigation.
### Who sees the admin area
`lib/admin-access.ts` is the single decision. Every page under `app/admin/**`
calls `requireAdminSection('<section>')` first (and `adminPageMetadata` for
its title); anyone who may not open it gets Next's `notFound()` — the same
404 as a page that does not exist, with no admin chrome. Signed-out visitors,
guests and ordinary members get 404 for the whole area (`admin/layout.tsx`
and the `@modal` interceptor call `requireAdminArea()`).
`lib/__tests__/admin-pages-guarded.test.ts` fails the build if a page skips
the guard.
| Section | Opens with (any of; Administrator and the owner open all) |
|---|---|
| Members | Kick Members, Ban Members, Manage Roles, Manage Community |
| Channels | Manage Channels |
| Roles | Manage Roles |
| Invites, Voice & Media, Apps, Bots | Manage Community |
| Audit log | View Audit Log |
| Overview, Plugins & Word Packs, Bandwidth, Authentication, Email, Storage, Backups, Moderation, Health, Updates | instance owner / operator token only |
On the official hub `/admin` is the operator's; a community's managers use
`/servers/{id}`, which needs Manage Community there (same 404 otherwise).
The settings nav, the lobby's community menu, the voice channel gear,
"install an app", the bot profile's settings link and the header's health
link are drawn only for sections the viewer may open
(`buildLobbyAdminLinks` in `lib/admin-sections.ts`).
The interaction contract is fixed:
- one `role="dialog"` with `aria-modal="true"` per settings route;
- an icon-only close button in the top-right corner;
- initial keyboard focus on that close button;
- both the close button and `Escape` replace the current route with `/lobby` on a self-hosted community; on the official hub they return to the page settings were opened from (tracked per tab by `SettingsReturnTracker`, validated by `lib/settings-return.ts`), or `/home`;
- the sidebar/header stays fixed while the settings content scrolls.
`e2e/settings-modal.spec.ts` protects this contract in Chromium.
- `/settings` is the first user settings surface. It loads or creates the
current materialized guest user's `user_settings` row through
`/api/settings/me`.
- User settings pages use a shared sticky save footer for account-synced and
browser-local preferences. The footer keeps save/reset actions visible while
the full-screen settings surface scrolls.
- `/api/settings/me` returns the normalized settings object and currently
accepts PATCH updates for privacy/activity visibility only.
- `/settings/profile` stores display name, custom status, avatar, and profile
banner. Avatar and banner uploads are authenticated, rate-limited data URL
endpoints for this milestone; production storage can replace the persistence
layer without changing the profile UI contract.
- `/settings/profile` also exposes the current community nickname. Member
lists, voice rosters, and chat author labels prefer the server nickname over
the global display name when one is set.
- `/settings/keybinds` persists bounded shortcut data in `user_settings.keybinds`.
Push-to-talk reads the saved key instead of hard-coding Space; other voice
actions are modeled up front so future shortcuts can be enabled without a
schema change.
- `/settings/appearance` stores the account theme through `/api/settings/me`
and stores accent, density, compact chat, avatar visibility, and empty-channel
hiding as local browser preferences. `AppearanceRuntime` runs from the root
layout so these preferences are re-applied on navigation and reload; the page
also previews changes immediately before save.
- `/admin/settings/members` loads real server membership data and supports
client-side search, role filtering, and sorting. Server nicknames are shown
beside global display names so admins can audit identity changes in context.
The member action drawer can assign multi-role sets, kick members, and ban
members through the guarded membership/moderation APIs. Owner and self-danger
actions are disabled in the UI and still rechecked server-side.
- `/admin/settings/channels` is wired to the channels API. Admins can create,
edit, reorder, and delete channels from the modal surface; every mutation is
still authorized by the server route's session, membership, rate-limit, and
`MANAGE_CHANNELS` checks.
- `/admin/settings/roles` is wired to the roles API. Admins can create roles,
edit name/color/position, toggle known core permissions, and delete
non-`@everyone` roles. Granting `administrator` shows an explicit client-side
confirmation, while the server remains the authority for permission checks.
- `/admin/settings/authentication` is wired to instance access settings. It
exposes open, invite-only, and closed registration modes, guest access, SEO
indexing, title, and description with a sticky save/reset footer. The PATCH
endpoint is admin-gated, rate-limited, schema-validated, and body-size
limited. The page links the first community to its canonical Access policy
tab for official identity, account linking, and first-join approval.
- `/admin/settings/invites` is wired to the invite APIs. Admins can create,
search, filter, copy active links, and confirm revocation. Public invite
metadata and redeem routes accept only the canonical 12-character invite
alphabet generated by the database helper, then normalize to uppercase
before lookup.
- `/admin/settings/voice-media` is wired to `server_voice_settings` through
`/api/servers/{id}/voice-settings`. Mutations require the caller to be a
server member with `MANAGE_SERVER` who is not timed out, use bounded zod
validation, and are rate-limited. Camera and screen-share toggles are
enforced at LiveKit token issuance by narrowing `canPublishSources`, and a
change is re-applied at once to everyone already connected
(`queueServerVoiceSync`). Every change is audited as
`server.voice_settings.update` with the fields it set.
- `/admin/settings/backups` no longer shows fabricated restore points or
successful backup status. It verifies the configured backup manifest, checks
freshness, digest shape, and artifact existence, and reports missing backup
worker configuration as an explicit warning.
- `/admin/settings/storage` reports real attachment usage by MIME bucket and
reclaimable bytes. Upload quotas are labeled as not enforced until a real
quota gate exists; the page no longer invents a placeholder quota number.
- `/admin/audit` loads the authorized community audit trail, resolves actor
display names, and provides client-side search, category filters, sorting,
metadata inspection, and CSV export. CSV cells are escaped and formula-like
values are neutralized before download. Row metadata is projected per
viewer, here and in `GET /api/servers/{id}/audit-logs` alike
(`projectAuditMetadata` in `lib/audit-log-view.ts`, security-review
AUTHZ-010): an invite `code` only for MANAGE_SERVER; a channel row's
`name`, `topic` and `visibleToRoleIds` only when the viewer may see that
channel; the Moderation Bot's `detail` (the matched word or link host) and
`excerpt` only when the viewer may see the channel the message was in.
Owners and MANAGE_CHANNELS holders see every channel. Old rows are covered
because the projection happens on read.
- On self-hosted instances, `/lobby` repairs a missing membership for the
first community only when the account is the setup owner or the instance is
in `registration_mode = open` (guest accounts also require guest access).
Invite-only and closed instances fail closed.
- `/lobby` voice channels render server-wide voice presence, not only the
locally joined room, so users can see who is already in each channel before
joining. Clicking the currently connected channel opens the full voice/video
view instead of disconnecting. Remote LiveKit audio tracks are attached to
hidden audio elements inside `LobbyVoiceProvider`; without this attachment
browsers can connect and publish but still not play peer audio.
- `/lobby` member sidebars group members as Online and Offline. Users currently
in voice remain in the Online group; a separate "In Voice" sidebar section is
intentionally not part of the canonical lobby.
- Clicking a person anywhere in the lobby (member list, chat author, voice
roster, DM header, your own account avatar) opens the one profile card;
"View full profile" opens a modal with About and Mutual communities tabs.
The card fills in from `GET /api/servers/{id}/members/{userId}/profile`,
projected per viewer, and offers only the actions its `viewerCan` allows.
See [PROFILES.md](./PROFILES.md). The block/unblock action uses
`/api/settings/me/blocks` and remains server-side authoritative; blocked
message bodies are masked before they reach the client.
- The lobby composer posts text messages through
`/api/servers/{id}/channels/{channelId}/messages`. Attachment, gift, and GIF
controls remain disabled until those feature-specific upload/picker flows are
implemented.
- Privacy defaults are intentionally conservative: profile, online status, and
activity visibility default to `server_members`; server name in public
activity text defaults to off.
- The activity switches cover current game, music/listening status, watch
party status, and whether to include the server name.
- Presence readers apply these settings before returning public snapshots:
hidden online status becomes `status: "hidden"` and disallowed activity
kinds are omitted from the response.
## Updates
- `/admin/updates` renders the guarded self-host update plan for admins.
- `/api/admin/updates?action=check|plan` exposes the same planner as JSON.
- `POST /api/admin/updates` supports dry-run, backup verification, and gated
apply/rollback. Live execution requires both update execution environment
flags, explicit request confirmation, maintenance mode, a verified signed
manifest, a verified backup, and an allowlisted no-shell worker plan.
## Voice streams and profiles
- Remote screen shares are opt-in. The LiveKit client connects with
`autoSubscribe: false`, automatically subscribes to microphone/camera, and
subscribes to screen-share video/audio only after `Join stream`. Leaving a
stream unsubscribes both tracks and stops downstream media delivery.
- Community admins set the maximum screen-share resolution and frame rate.
Members may choose the configured maximum or a lower value; capture
constraints are clamped client-side to the policy returned with the signed
room token.
- Voice-only full-screen tiles use a compact fixed layout. Camera and joined
screen-share tracks retain the responsive stage/filmstrip layout.
- The full-screen call dock groups connection state, microphone, deafen,
camera, screen share/quality, Voice & Video settings, and disconnect in one
compact bottom control surface. Opening Voice & Video settings does not leave
the active room.
- Text-only lobby sessions refresh their Redis presence every 30 seconds; the
5-second voice heartbeat takes over while connected. Visibility/focus also
triggers a refresh, so an open lobby does not fall offline after the 90-second
Redis TTL.
- Camera and screen share are independent media surfaces. A publisher first
sees a compact local stream preview; joining the stream promotes it to the
stage. When the same participant also has a camera track, desktop renders the
two sources side by side and narrow viewports stack them. The participant is
removed from the auxiliary filmstrip to avoid a third duplicate tile.
- The Voice View participant order is a persisted local preference: default,
camera first, or alphabetical. Voice-channel member rows expose camera and
screen-share icons without subscribing the viewer to screen media. Those
indicators are derived from unmuted, live LiveKit publications and are
removed after publication teardown completes.
- Disconnect is optimistic and idempotent: it clears the local call UI and
presence state even if the LiveKit room reference was already lost, then
finishes transport cleanup when a room still exists.
- The profile card shows the bio, the custom status, all assigned roles, and
a local per-participant volume slider while that member is in voice
([PROFILES.md](./PROFILES.md)).
- Role icons use a fixed Material Symbols allowlist validated by both Zod and
a PostgreSQL check constraint. Role color values remain strict six-digit
hex colors.
## Deployment shell
- Official deployments render the global instance rail and add-community
entry points.
- Self-host deployments render a single-community shell: the community
channel sidebar starts at the left edge and official instance switching is
absent. A desktop client may provide its own native account/instance switcher.
## Local development
```sh
# From the repo root
pnpm install
pnpm -r build # rebuild dist-pointer packages (@lobbyforge/core, …)
pnpm --filter @lobbyforge/web dev # next dev on :19520
```
The web app reads from `infra/docker/docker-compose.dev.yml` for PostgreSQL / Redis / LiveKit. The Doctor HTTP probes target `localhost:<port>` by default; override with `LIVEKIT_URL`, `POSTGRES_URL`, `REDIS_URL`, `NEXT_PUBLIC_BASE_URL` in a `.env.local` if you point at another host.
Required env:
- `LOBBYFORGE_SESSION_SECRET` — HMAC key for the `lf_guest` cookie (32+ chars).
- `DATABASE_URL` — Postgres connection string for `@lobbyforge/db` (default points at the docker-compose dev stack).
- `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET` — credentials for the LiveKit token endpoint.
Optional bot protection overrides (they win over Admin → Settings →
Authentication → Bot protection and show as locked there; see
[CAPTCHA.md](./CAPTCHA.md)):
- `LOBBYFORGE_CAPTCHA_PROVIDER` — `none` | `altcha` | `turnstile` | `recaptcha`;
`none` is the emergency switch.
- `LOBBYFORGE_CAPTCHA_SITE_KEY` / `LOBBYFORGE_CAPTCHA_SECRET_KEY` — the
external provider's key pair.
The full env list is in `infra/docker/.env.example`.
`next build` produces:
```
Route (app) Size First Load JS
┌ ○ / 170 B 102 kB
├ ○ /_not-found 993 B 103 kB
├ ƒ /admin/health 170 B 102 kB
├ ƒ /api/auth/guest 170 B 102 kB
├ ƒ /api/doctor 170 B 102 kB
├ ƒ /api/health 170 B 102 kB
├ ƒ /api/livekit/token 170 B 102 kB
├ ƒ /api/presence 170 B 102 kB
├ ƒ /api/servers 170 B 102 kB
├ ƒ /api/servers/[id] 170 B 102 kB
├ ƒ /api/servers/[id]/channels 170 B 102 kB
├ ƒ /api/servers/[id]/channels/[channelId] 170 B 102 kB
├ ƒ /api/servers/[id]/channels/[channelId]/messages 170 B 102 kB
├ ƒ /api/servers/[id]/channels/[channelId]/messages/[messageId] 170 B 102 kB
├ ƒ /api/servers/[id]/members 170 B 102 kB
├ ƒ /api/servers/[id]/members/[userId] 170 B 102 kB
├ ƒ /api/servers/[id]/members/[userId]/role 170 B 102 kB
├ ƒ /api/servers/[id]/roles 170 B 102 kB
├ ƒ /api/servers/[id]/roles/[roleId] 170 B 102 kB
├ ƒ /api/test/db-reset 170 B 102 kB
├ ƒ /api/test/redis-reset 170 B 102 kB
└ ○ /connect 1.69 kB 103 kB
```
`○` = static, `ƒ` = dynamic (server-rendered on demand).
## What is intentionally not here
- **A custom design system / Tailwind config in apps/web.** Styling is inline for now. Once the design pass lands, `@lobbyforge/ui` will host the tokens and `apps/web` will import them.
- **A live LiveKit room UI.** The Phase 1 success criterion is the two-browser voice test; the `/connect` page stops at token issuance, the SDK `connect()` lands with M11.
- **Messages.** `POST /api/servers/{id}/channels/{channelId}/messages` is **M12 (done)**. The five endpoints landed with M12; reactions, attachments, search, and real-time are the follow-ups.
- **Roles & permissions.** **M13 (done).** The 5 role-management + 3 membership routes are wired; the M11 + M12 mutation routes are gated by `authorizeServerPermission`. Invite-redeem, bans, audit log, role reordering UI, and multiple-roles-per-member are M14.
- **Voice presence.** "Who is in this voice channel" is **M14**. The `app/api/presence/route.ts` stub (user-added) wires the Redis-backed presence layer; the productionization (Redis-backed rate limit, integration with the channel UI) lands with M14's full moderation pass.
- **A production Dockerfile.** Lives under `infra/docker/` and ships in the Installer phase (Phase 5).
These are tracked in [`docs/CHANGELOG.md`](./CHANGELOG.md) under the "Out of Scope" section for the most recent milestone.