# Activities — Aşama 3 / Plugin SDK minimal The activities layer is the plugin-host surface in the web app. It exposes a small set of HTTP endpoints under `/api/servers/{id}/...` that the voice room's "Start Activity" picker calls, and a `gameSessions` query helper in `@lobbyforge/db` that the host uses to persist plugin state. This document covers: - The plugin registry (`apps/web/lib/plugin-registry.ts`). - The `gameSessions` query helpers. - The 5 activity HTTP endpoints. - The audit-log actions an activity produces. - The `START_ACTIVITY` permission gate. - The `ActivityPicker` and `ActivityPanel` UI in the voice room. The plugin SDK contract (`GamePlugin` / `GamePluginContext` / `createTestHarness`) is documented in [`docs/PLUGIN_SDK.md`](./PLUGIN_SDK.md). ## The plugin registry `apps/web/lib/plugin-registry.ts` is the host's compiled-in list of plugins. M16 ships with exactly one plugin (`@lobbyforge/quiz`, used as the "dummy plugin" for the Aşama 3 success criterion). ```ts import { quizPlugin } from '@lobbyforge/quiz'; import { registerGamePlugin, type RegisteredGamePlugin } from '@lobbyforge/plugin-sdk'; export const PLUGINS: readonly RegisteredGamePlugin[] = [ registerGamePlugin(quizPlugin), ] as const; export function getPlugin(id: string): RegisteredGamePlugin | null { return PLUGINS.find((p) => p.manifest.id === id) ?? null; } export function listPluginSummaries(): PluginSummary[] { return PLUGINS.map((p) => ({ id: p.manifest.id, name: p.manifest.name, version: p.manifest.version, type: p.manifest.type, catalog: p.manifest.catalog ?? null, })); } ``` The registry is a static array (not dynamic import). Adding a new plugin is a code change + redeploy today; M17+ will add hot-reload. The `PluginId` type — `(typeof PLUGINS)[number]['manifest']['id']` — narrows the route params + audit-log metadata at compile time. ## The `gameSessions` query helpers `packages/db/src/queries/gameSessions.ts` is the DB layer for the host. The schema (`game_sessions` + `game_session_players` + `plugin_events`) was created in M3; M16 wires the helpers. - `createGameSession(db, { serverId, channelId, pluginId, createdBy, state })` — inserts a new session with `status = 'lobby'`. The plugin's `createInitialState` is what produces the `state` argument. - `getGameSessionById(db, sessionId)` — returns the row only if `status <> 'ended'` and `endedAt IS NULL`. The read path treats ended sessions as gone. - `listGameSessionsForChannel(db, channelId)` — same filter, newest first, bounded to 50. - `setGameSessionState(db, sessionId, state, publicSummary?)` — patches the `state` JSONB and optionally the `publicSummary` (used by the list view to avoid a full-state read). - `endGameSession(db, sessionId)` — sets `status = 'ended'` and `endedAt = now()`. Idempotent: re-ending a session is a no-op (the row's `status` is already `'ended'`). - `addPlayerToSession(db, sessionId, userId)` / `removePlayerFromSession(db, sessionId, userId)` / `listPlayersForSession(db, sessionId)` — manage the `game_session_players` table. Player membership is a richer concept than the SDK's `players.list` snapshot, so the table carries `joinedAt`, `leftAt`, `characterName`, `characterData`, `status`, and `score`. M16 only uses the snapshot path (`listPlayersForSession` → cached at the start of the call); the per-player history is M17+. ## The 5 activity HTTP endpoints All 5 are behind `withApiSecurity` and use `getUserPermissions` + `isServerMember` (the same gate pattern as the M13 routes). Audit writes follow the M15.2 fire-and-forget pattern. Security invariants: - A voice/stage channel can have only one active activity at a time. - Activity start requires `START_ACTIVITY`. - Activity actions are authorized by `plugin.actionPolicies` before the plugin reducer runs. - Unknown action types default to host-only. - Actor identity fields such as `playerId`, `voterId`, and `hostId` must be host-overwritten through `actorFields`, not trusted from the browser. ### `POST /api/servers/{id}/channels/{channelId}/activities` — start Body: `{ pluginId: string }`. Requires `START_ACTIVITY` (admin-only, seeded into `DEFAULT_ADMIN_PERMISSIONS` in M15.2). The route resolves the plugin via `getPlugin`, calls `plugin.createInitialState(buildHttpPluginContext(...))` to compute the initial state, inserts the row via `createGameSession`, and audit-logs `activity.create` with `{ pluginId, channelId }`. Rate limit: 10 req/min. 404 on unknown plugin; 400 on malformed body; 201 on success. Current hardening: - The route verifies that `channelId` belongs to `{id}`. - The channel must be `voice` or `stage`. - The route calls `getActiveGameSessionForChannel` and returns 409 when the channel already has an active `lobby`, `running`, or `paused` session: `{ error, code: 'activity_exists', sessionId, activity }`. Two starts at the same moment both pass that check; the partial unique index `game_sessions_channel_open_unique` lets one INSERT in, and the loser's unique violation (seen through Drizzle's wrapper with `isPgUniqueViolation`) gets the same 409 with the winner's `sessionId` — never a 500. - The selected plugin must be installed and enabled for the server in `plugins_enabled` (otherwise 403 `app_not_installed`). - A member who is timed out in the community starts nothing: 403 `timed_out` with `until`. ### `GET /api/servers/{id}/channels/{channelId}/activities` — list Returns the active sessions in the channel as summaries (`{ id, serverId, channelId, pluginId, status, publicSummary, ... }`) without the full `state` blob. Membership-gated (owner shortcut or `isServerMember` = true). 60 req/min. ### `GET /api/servers/{id]/activities/{sessionId}` — read Returns the full session detail: `{ id, pluginId, status, state, publicSummary, createdBy, players: [...] }` (`createdBy` is the current host). Membership-gated. 404 if the session is ended, doesn't exist, or belongs to a different server than the URL says (defence-in-depth — the URL encodes the server, but a malicious caller shouldn't be able to cross-invoke). 60 req/min. For a plugin that requires voice the read also applies the host hand-over rule and adds `host: { userId, inVoice, awaySince, transferAt, abandonAt, abandoned }` — see [PLUGIN_SDK.md → Voice, hosting and play again](./PLUGIN_SDK.md#voice-hosting-and-play-again). A read also fires the plugin's timers that are due (under the session lock, earliest first), and always returns `timersDueAt` (ISO due times of the pending timers, never their actions) and `now` (the server clock), so an open panel re-reads at the earliest — see [PLUGIN_SDK.md → Effects](./PLUGIN_SDK.md#effects-timers-storage-and-chat). ### `POST /api/servers/{id]/activities/{sessionId]/actions` — dispatch Body: any JSON object with at least a `type: string` field. The route loads the session, resolves the plugin via `getPlugin(session.pluginId)`, and calls `plugin.handleAction(ctx, state, body)`. The return value is persisted via `setGameSessionState`. Audit `activity.action` with `{ pluginId, actionType }`. Membership-gated. 409 if the session is for a plugin the registry no longer ships. Rate limits: 90 actions/min per user per session (keyed on the signed-in user, so a household or a LAN party behind one address no longer shares a bucket), plus a 600/min per-address backstop; both answer 429 `rate_limited`. Before `handleAction` runs, the host evaluates the plugin's action policy: - `host`: the session's host (its creator, until hosting moves) or a user with `START_ACTIVITY`; otherwise 403 `not_host`. - `member`: any member of the server. - `player`: active player in `game_session_players`; otherwise 403 `not_player`. - missing policy: treated as `host`. - a plugin with `catalog.requiresVoiceRoom`: `member` / `player` actions also need the caller in the activity's voice room (403 `voice_required`), unless the policy says `allowOutsideVoice`. - an ended session is 409 `session_ended`; so is any action on a finished game (`phase: 'ended'`) that is not one of the plugin's `restartActions`. - `system`: never from a client (403 `system_action`); the host runs it when one of the plugin's timers falls due. - a member who is timed out in the community: 403 `timed_out` (with `until`) for every action — host actions included — except one whose policy says `allowOutsideVoice` (leaving). - an app that is no longer installed or enabled in the community: 403 `app_not_installed` (uninstalling or disabling it ends its sessions, so this only meets a session left open by a race). The reducer may return effects with the state (`withEffects`): timers, storage writes, chat lines. An invalid or unpermitted one refuses the whole action (422 `effect_refused`). Timers that fell due before the action fire first, in the same write; storage writes and chat lines go after the commit. One request fires at most 8 timers: when more were due, what fired commits and the action answers a retryable 409 `timers_pending` (the lobby retries it). The response carries the new `timersDueAt`. Details: [PLUGIN_SDK.md → Effects](./PLUGIN_SDK.md#effects-timers-storage-and-chat). If the policy has `actorFields`, the host overwrites those fields with the local `session.uid`. For example, `vote.voterId`, `join.playerId`, and `set-video.hostId` should be actor-bound by the host. ### `POST /api/servers/{id]/activities/{sessionId]/end` — end The host (session's `createdBy`) can end without `START_ACTIVITY`; so can anyone holding it. For a plugin that requires voice, a host who left the voice room no longer locks the channel: past 60 s hosting moves to the longest-present participant in the room (who may then end it as host), and once the session is abandoned (host gone 3 min, or 60 s with nobody to take over) any voice participant may end it — someone outside the room gets 403 `voice_required`, a participant who may not end it yet 403 `not_host` with the `host` object. Plugin timers that were already due fire first; every other pending timer is dropped with the session. Audit `activity.end` with `{ pluginId, wasHost }` (+ `reason: 'abandoned'`). 10 req/min. ### `GET /api/plugins` — registry listing A small `apps/web/app/api/plugins/route.ts` that returns `listPluginSummaries()`. No auth (the picker is inside a server gated by the voice room). 60 req/min. Summaries include optional `manifest.catalog` metadata such as publisher, trust level, tags, player limits, spectator/queue support, and overflow policy. ### `GET/POST/DELETE /api/servers/{id}/apps` — installed apps The server-scoped installed-apps route joins the compiled-in catalog with the server's `plugins_enabled` rows: - `GET` requires server membership and returns every known catalog app with `installed`, `enabled`, `settings`, and `installedAt`. - `POST` requires `MANAGE_SERVER`, validates `pluginId`, and upserts `enabled` plus supported settings (`allowedChannelIds`, `allowedRoleIds`, `defaultMaxPlayers`, `overflowPolicy`, `chatChannelId`). A `chatChannelId` must be a text or announcement channel of the community (400 `invalid_chat_channel`); choosing a role-gated one also needs Manage Channels and seeing it (403 `chat_channel_not_allowed`), as granting a bot that channel does. Saving `enabled: false` ends the app's open sessions in the community. - `DELETE` requires `MANAGE_SERVER`, removes the server-local install row and ends the app's open sessions in the community. Ending them on uninstall or disable is audited per session as `activity.end` with `{ pluginId, wasHost, reason: 'app_uninstalled' | 'app_disabled' }` (the actor is whoever removed the app) and published on the activity bus so open panels close; nothing of the plugin runs on the way out. The route never installs arbitrary package code. It can only enable plugins already compiled into `apps/web/lib/plugin-registry.ts`. ## The `ActivityPicker` and `ActivityPanel` UI The voice room page at `apps/web/app/room/[roomName]/page.tsx` embeds two subcomponents: - **ActivityPicker.** A `