docs/ACTIVITIES.md
Rama main · réplica de lectura del repositorio original.
# 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 `<select>` populated by
`GET /api/servers/{id}/apps` filtered to installed+enabled apps, plus a
"Start activity" button that POSTs to the channel's `activities` route.
Hidden if the URL doesn't carry `serverId` + `channelId`, or if an activity
is already active in this view.
- **ActivityPanel.** Renders only while `activeSessionId` is set.
Polls `GET /api/servers/{id]/activities/{sessionId}` every 2s
and shows: the plugin name, the session `status`, the player
count, the current `state` (as a JSON dump), a free-form "send
action" input (the action is parsed as JSON and POSTed to the
`actions` route), and an "End" button. 404 from the poll
triggers an `onEnd` (the session was ended from another tab).
The panel's free-form JSON action input is M16's "good enough" UI
for the dummy plugin. M17+ will swap to a per-plugin
`renderClient` that knows its own action types.
## Product UI direction for Games
The user-facing language should be **Apps** at the catalog level and
**Games / Bots / Integrations** as subcategories. "Plugin" remains a
developer term.
Voice-room game picking should stay compact and action-oriented:
```txt
Start Activity
- Hushle 4-12 players Official
- Vampire Village 6-24 players Official
- Quiz 2-50 players Official
- Watch Party 2-100 sync-only Official
```
The broader official lobby / registry view can use richer app cards:
- trust label: official, verified community, unverified self-host
- install state: installed, available, incompatible, update available
- player range and spectator support
- required capabilities summarized in user language
- account requirement: none, optional account linking, external account required
- primary action: `Start`, `Install to server`, `Open in official hub`, `Connect account`
MVP invariant:
```txt
1 voice room = 1 active activity session
```
The start route must reject a second active session in the same voice
channel. This keeps voice state, host controls, mute/deafen behavior,
and LiveKit data-channel routing predictable.
## Voice in the lobby's activities view
In the lobby's activities view (`app/lobby/LobbyActivityView.tsx`), a
`voice_required` refusal is no longer a dead end
([ACTIVITY_INTEGRITY_AND_RESILIENCE.md](ACTIVITY_INTEGRITY_AND_RESILIENCE.md)
§3.2):
- **A standing Join.** Some games need voice: the installed app says
`requiresVoice` (from `catalog.requiresVoiceRoom`), or the compiled-in
manifest does. In such a game, a viewer outside that channel's voice
room sees a bar under the status rail: "Join the voice channel to play
[Join]" / "Oynamak için sesli kanala katıl [Katıl]". Join uses the
channel list's own join for this channel, and switches over from another
voice channel if needed. The bar only informs: watching never needs
voice, and the panel stays usable.
- **Join next to a refusal.** A move refused with `voice_required` shows
the same Join next to the message. `useActivitySession` returns the
refusal's `errorCode` along with `error` for this.
- **After a drop.** When the "You dropped out of voice" strip is for this
channel, it shows in the panel instead of the bar. A reload reopens the
activities view. Both are described in
[VOICE_ROOM.md](VOICE_ROOM.md) → "Coming back after a reload or a drop".
## The `START_ACTIVITY` permission
`CorePermission.START_ACTIVITY` was added in M15.2 and lives in
`DEFAULT_ADMIN_PERMISSIONS` (not `DEFAULT_EVERYONE_PERMISSIONS`).
The `end` route allows the host (the session's `createdBy`) to
end without the permission, on the "I started this, I can stop it"
principle. The start route requires the permission — anyone who
can start an activity can spam the channel, so the gate is tight.
## The audit log
Activity events land in `audit_logs` with these `action` strings:
- `activity.create` — `targetType: 'session'`, `targetId: <sessionId>`,
`metadata: { pluginId, channelId }`.
- `activity.action` — `targetType: 'session'`, `targetId: <sessionId>`,
`metadata: { pluginId, actionType }`.
- `activity.end` — `targetType: 'session'`, `targetId: <sessionId>`,
`metadata: { pluginId, wasHost }`, plus `reason: 'abandoned'` when a
voice participant ended a session its host had abandoned.
- `activity.host_transfer` — `actorUserId: null`, `targetType: 'session'`,
`metadata: { pluginId, fromUserId, toUserId, reason: 'host_left_voice',
awaySeconds }`: hosting moved because the host left the voice room.
The audit log surface is owner-only (via `VIEW_AUDIT_LOG`, which is
admin-only). See [`docs/ROLES.md`](./ROLES.md).
## Reference
- `packages/db/src/queries/gameSessions.ts` — query helpers.
- `apps/web/lib/plugin-registry.ts` — compiled-in plugin list.
- `apps/web/lib/plugin-context.ts` — HTTP host's
`buildHttpPluginContext` (the `GamePluginContext` adapter).
- `apps/web/app/api/plugins/route.ts` — `GET /api/plugins`.
- `apps/web/app/api/servers/[id]/channels/[channelId]/activities/route.ts`
— start + list.
- `apps/web/app/api/servers/[id]/activities/[sessionId]/route.ts` — read.
- `apps/web/app/api/servers/[id]/activities/[sessionId]/actions/route.ts`
— dispatch.
- `apps/web/app/api/servers/[id]/activities/[sessionId]/end/route.ts`
— end.
- `apps/web/app/room/[roomName]/page.tsx` — the picker + panel UI.