# Channels API — M11 / Phase 2 The `channels` API is the second Phase 2 surface (after M10's servers API). A channel is the unit of (a) text chat and (b) voice / activity rooms; it lives inside a server and is the parent of every message and every active voice session. This document covers: - The DB query helpers ([`packages/db/src/queries/channels.ts`](../packages/db/src/queries/channels.ts), [`packages/db/src/queries/memberships.ts`](../packages/db/src/queries/memberships.ts)) - The five HTTP endpoints (list / create / get / patch / delete) - The auth model (member-or-owner for reads, owner-only for mutations) - The relationship to the M10 servers API - The out-of-scope list (messages, voice presence, permissions, roles, audit log) ## Auth model M11 flips the `GET /api/servers/{id}` rule from "owner-only" (M10) to **"any member of the server"**. The `isServerMember(db, userId, serverId)` helper in `packages/db/src/queries/memberships.ts` is the single source of truth. The owner is auto-added as a member by `createServer`, so the owner-only case is implicit in the membership lookup. For channel operations: | Operation | Required relationship | |---|---| | `GET /api/servers/{id}/channels` | Member of the server | | `POST /api/servers/{id}/channels` | Member of the server (permission check placeholder; real roles land with M13) | | `GET /api/servers/{id}/channels/{channelId}` | Member of the server | | `PATCH /api/servers/{id}/channels/{channelId}` | **Owner** of the server (or someone with `MANAGE_CHANNELS` once M13 lands) | | `DELETE /api/servers/{id}/channels/{channelId}` | **Owner** of the server | The "channel belongs to a different server" case (URL says `servers/A/channels/X` but `X.serverId === B`) returns **404**, not 403, so the route doesn't leak the existence of channels in other servers. ## Endpoints | Method | Path | Body | Description | Rate limit | |---|---|---|---|---| | `GET` | `/api/servers/{id}/channels` | — | List channels in a server, ordered by `position` ASC. | 60 / min | | `POST` | `/api/servers/{id}/channels` | `{ name, type, topic?, pluginId?, position? }` | Create a channel. Position defaults to `max(position) + 1`. | 10 / min | | `GET` | `/api/servers/{id}/channels/{channelId}` | — | Fetch a single channel. | 60 / min | | `PATCH` | `/api/servers/{id}/channels/{channelId}` | `{ name?, topic?, position? }` | Partial update. | 30 / min | | `DELETE` | `/api/servers/{id}/channels/{channelId}` | — | Hard-delete (the `channels` schema has no `deletedAt`). | 10 / min | All five go through `withApiSecurity(...)` (now generic over `TContext`, see [M10 SERVERS docs](SERVERS.md)) so they inherit the standard security headers, the 405 + `Allow` behavior, and the in-process token-bucket rate limit. ### Atomic Position Shifting (M15.6) `updateChannel` now performs an atomic "move-and-shift" operation when the `position` is updated. It uses a database transaction to reorder sibling channels, ensuring no gaps or duplicates in the position sequence. This provides stable, predictable ordering for the channel list. ### `POST /api/servers/{id}/channels` body ```ts interface CreateChannelBody { name: string; // 1..100 chars, no leading '#' (ChannelNameSchema in @lobbyforge/core) type: 'text' | 'voice' | 'activity' | 'announcement' | 'stage'; topic?: string; // ≤ 512 chars pluginId?: string; // ≤ 64 chars, used for 'activity' channels position?: number; // ≥ 0, defaults to max+1 } ``` ### Common response shapes ```ts // Channel JSON (toJson in route.ts) interface ChannelJson { id: string; // uuid serverId: string; // uuid name: string; // 1..100 chars type: 'text' | 'voice' | 'activity' | 'announcement' | 'stage'; position: number; // dense, server-scoped pluginId: string | null; topic: string | null; createdAt: string; // ISO-8601 } ``` ### Status codes | Code | When | |---:|---| | 200 | `GET` / `PATCH` success | | 201 | `POST` success | | 400 | Body fails zod validation (name starts with `#`, type not in enum, etc.) | | 401 | Cookie missing or signature invalid | | 403 | Caller is not a member (reads) or not the owner (mutations) | | 404 | Server not found, or channel not found, or channel doesn't belong to the URL's server | | 500 | Drizzle threw (wrapped with a sanitized message) | | 503 | Cookie has no `uid` (DB not migrated / down) — see "503 contract" below | ### The 503 contract If the cookie's `gid` has been minted but the `users` row was never materialized (e.g. Postgres was down at mint time and the `findOrCreateGuestUser` retry path failed), the cookie still has a `null` `uid`. M11 routes — like M10 — return **503** with `howToFix: "Re-issue POST /api/auth/guest"`. The fix-the-DB path is "make sure the DB is up, then re-run POST /api/auth/guest" — the second call materializes the row and the existing cookie is replaced. ## Position semantics A new channel with no explicit `position` lands at `max(position) + 1` for the server. If the server has no channels yet, it lands at `0`. Positions are dense within a server (no gaps unless `PATCH` explicitly sets one), and the `listChannelsForServer` query sorts `position ASC` so the "general" channel is at the top. `PATCH` can move a channel to a specific `position`, but the route does not renumber siblings — if you insert `position = 1` into `[0, 2, 3]`, you get `[0, 1, 1, 3]`. A future iteration adds a "move and shift" helper (Discord does the shift client-side today; we'll do it server-side when drag-and-drop reordering lands). ## Soft delete Channels have **no** `deleted_at` column. Server-level soft delete cascades through the `channels.serverId` foreign key (`ON DELETE CASCADE`), removing the channels implicitly. Explicit channel removal is a hard `DELETE`, owner-only. ## DB schema (no delta from M10) The `channels` table was in the M3 schema; M11 just exercises it. The only Drizzle change is the `position` ordering and the `nextPosition` computation in `createChannel` (described above). ## Query helpers ```ts // packages/db/src/queries/channels.ts createChannel(db, { serverId, name, type, position?, topic?, pluginId? }) // Asserts the parent server exists (not soft-deleted). Computes the // next position as max(position) + 1 if `position` is not supplied. listChannelsForServer(db, serverId, { limit? }) // Joins servers, filters soft-deleted, sorts by position ASC. Default // limit 200, hard cap 500. getChannelById(db, channelId) // Joins servers, filters soft-deleted parents. Returns null if the // channel doesn't exist OR its parent server is soft-deleted. updateChannel(db, channelId, { name?, topic?, position? }) // Partial update. Empty patch is a no-op that still returns the // current row (so the route can return 200 with the current state). deleteChannel(db, channelId) // Hard delete. Cascades through messages / reactions / attachments. ``` ```ts // packages/db/src/queries/memberships.ts isServerMember(db, userId, serverId): Promise // Joins users to filter out soft-deleted accounts. The "owner" case // is implicit (createServer inserts an owner membership). getServerMember(db, serverId, userId): Promise // Returns the membership row (with roleId, nickname) or null. Used // by future permission checks once roles land. ``` ## Testing `apps/web/app/api/servers/[id]/channels/__tests__/channels.test.ts` covers all five endpoints with `vi.mock` for `@lobbyforge/db` and `@/lib/security-headers`: | Scenario | Expected status | |---|---:| | `GET` without cookie | 401 | | `GET` for a missing server | 404 | | `GET` for a server where the caller is not a member | 403 | | `GET` for a server where the caller is a member, 2 channels returned in order | 200 | | `POST` with a name starting with `#` | 400 | | `POST` with an unknown type | 400 | | `POST` happy path | 201, `createChannel` called with the right `serverId` and `name` | | `GET /{channelId}` for a missing channel | 404 | | `GET /{channelId}` for a channel in a different server | 404 | | `GET /{channelId}` to a non-owner member | 200 | | `PATCH /{channelId}` by a non-owner | 403 | | `PATCH /{channelId}` by the owner | 200, `updateChannel` called | | `DELETE /{channelId}` by a non-owner | 403 | | `DELETE /{channelId}` by the owner | 200, `deleteChannel` called | The cookie sent in each request is a real signed `lf_guest` (built via `buildGuestSessionCookie`) so `readGuestSession` sees the production code path. ## Relationship to M10 - `GET /api/servers/{id}` now uses `isServerMember` (not a direct owner-equality check). The M10 test that asserted "non-owner → 403" was renamed to "non-member → 403" and the M10 "owner → 200" test was renamed to "member → 200" (the mock returns `isServerMember = true` regardless of the actual `ownerUserId`). - `POST /api/servers` still auto-inserts the owner membership (so the owner can immediately `GET /api/servers/{id}/channels` and see the empty list). - The "membership check" is what unlocks future M11+ endpoints (membership list, invite-redeem, etc.) — the helper is the primitive every other route leans on. ## Out of scope (deferred to later milestones) - **Messages.** `POST /api/servers/{id}/channels/{channelId}/messages` is **M12 (done)**. See [`docs/MESSAGES.md`](./MESSAGES.md) for the five endpoints, the author-or-owner mutation rule, the soft-delete story, and the out-of-scope list (reactions, attachments, search, plugins, real-time). - **Voice presence.** "Who is in this voice channel" is M14 (Redis presence layer + LiveKit `RoomServiceClient.listParticipants`). Today's `channel.type === 'voice'` is just a label. - **Permission checks on `POST /api/servers/{id}/channels`.** Today any member can create a channel. Once roles land with M13, the route gains a `MANAGE_CHANNELS` check; the `hasPermission` call is already in the route as a placeholder. - **Drag-and-drop reordering.** `PATCH` accepts a new `position` but doesn't renumber siblings. The "move and shift" helper is M14. - **Bulk create / bulk delete.** A `POST /api/servers/{id}/channels/bulk` endpoint is in the design notes but not implemented; it's a thin wrapper over the existing single-create query. - **Audit logging.** Channel create / update / delete are not yet logged. The shape is in the route, the audit-log table writes land with M14. - **Channel-level rate limits.** Today the per-server limit (`channels-create` rate-limit identifier) is shared across all channels in a server. A future iteration namespaces by `(serverId, action)`. - **Topic edit history.** `topic` is overwritten in place. Discord-like "edited by X at Y" lands when the audit-log integration ships. - **Activity-channel plugin resolution.** `pluginId` is a string; the actual plugin manifest is loaded server-side at activity-start time (M14).