docs/SERVERS.md
Rama main · réplica de lectura del repositorio original.
# Servers API — M10 / Phase 2
The `servers` API is the first Phase 2 surface. It gives an authenticated user
the ability to list, create, and read the Discord-like guilds ("servers") that
they own. M10 wires the API to the real Postgres schema; it does not yet
expose membership / channel / role / invite endpoints — those are scheduled
for the next Phase 2 milestones.
This document covers:
- The DB schema additions (just `users.guest_key` — see [`packages/db/src/schema.ts`](../packages/db/src/schema.ts))
- The query helpers ([`packages/db/src/queries/users.ts`](../packages/db/src/queries/users.ts), [`packages/db/src/queries/servers.ts`](../packages/db/src/queries/servers.ts))
- The DB singleton in the web app ([`apps/web/lib/db.ts`](../apps/web/lib/db.ts))
- The HTTP endpoints ([`apps/web/app/api/servers/route.ts`](../apps/web/app/api/servers/route.ts), [`apps/web/app/api/servers/[id]/route.ts`](../apps/web/app/api/servers/[id]/route.ts))
- How it composes with the M9 guest auth cookie
## Auth flow (recap)
All three endpoints require a valid `lf_guest` cookie (see [`docs/GUEST_AUTH.md`](GUEST_AUTH.md)). The cookie's `gid` is the stable per-session identity; the `uid` is the UUID of the materialized `users` row.
```
┌────────┐ POST /api/auth/guest ┌──────────────┐ INSERT users ┌─────────┐
│Browser ├────────────────────────▶│ auth/guest ├─────────────────▶│ Postgres│
│ │ Set-Cookie: lf_guest │ route │ ON CONFLICT │ users │
│ │◀────────────────────────┤ (M9 + M10) │ DO NOTHING └─────────┘
└────────┘ └──────────────┘
│
│ POST /api/servers
│ Cookie: lf_guest
▼
┌──────────────┐ readGuestSession ┌─────────┐
│ servers ├───────────────────▶│ Cookie │
│ route │ uid from payload │ payload │
│ (M10) │◀───────────────────┤ │
│ │ └─────────┘
│ │ createServer(db, ┌─────────┐
│ ├───────────────────▶│ Postgres│
│ │ { ownerUserId }) │ servers │
│ │ │ + memberships │
└──────────────┘ └─────────┘
```
The `uid` field is what makes M10 work: the cookie minted in M9 had no
materialized user record, so Phase 1 routes (`/api/auth/guest`,
`/api/livekit/token`) could work anonymously. Phase 2 routes — anything
that touches the `servers` or `memberships` table — refuse to run unless
the cookie carries a `uid`. The auth flow above materializes the user
in the same request that mints the cookie, so the very first call from a
fresh browser already has a `uid`.
## Endpoints
| Method | Path | Body | Description | Rate limit |
|---|---|---|---|---|
| `GET` | `/api/servers` | — | List servers the caller is a member of (owner rows included). | 60 / min |
| `POST` | `/api/servers` | `{ name, slug?, isPublic?, defaultLocale? }` | Create a server. The caller is set as the owner and auto-added as a member. | 10 / min |
| `GET` | `/api/servers/{id}` | — | Fetch a single server. **Owner-only** in M10 (membership check is M11). | 60 / min |
All three are wrapped in `withApiSecurity(...)` from [`apps/web/lib/security-headers.ts`](../apps/web/lib/security-headers.ts), which adds the standard `X-Content-Type-Options / X-Frame-Options / Referrer-Policy / Permissions-Policy` headers, enforces the method allowlist with a `405 + Allow` response, and applies an in-process token-bucket rate limit (per-identifier, defaults to "servers-list" / "servers-create" / "servers-get-one").
### Common response shapes
```ts
// Server JSON (toJson in route.ts)
interface ServerJson {
id: string; // uuid
name: string; // 2..32 chars
slug: string | null; // kebab-case when present
ownerUserId: string; // uuid
iconUrl: string | null;
defaultLocale: string; // "en", "tr", …
isPublic: boolean;
createdAt: string; // ISO-8601
deletedAt: string | null;
}
```
### Status codes
| Code | When |
|---:|---|
| 200 | `GET /api/servers` and `GET /api/servers/{id}` (owner) success |
| 201 | `POST /api/servers` success |
| 400 | Malformed body / fails zod validation (name too short, slug invalid, etc.) |
| 401 | Cookie missing or signature invalid |
| 403 | `GET /api/servers/{id}` called by a non-owner |
| 404 | `GET /api/servers/{id}` for an unknown id |
| 500 | Drizzle threw (wrapped with a sanitized message — see "Error surfacing" below) |
| 503 | The cookie has a `gid` but no `uid` — usually means Postgres is down or the schema is not migrated. The body includes `howToFix: "Re-issue POST /api/auth/guest"` |
### `POST /api/servers` body
```ts
interface CreateServerBody {
name: string; // 2..32 chars (ServerNameSchema in @lobbyforge/core)
slug?: string; // kebab-case (SlugSchema), optional
isPublic?: boolean; // default false
defaultLocale?: string; // 2..8 chars, default "en"
}
```
The first server a user creates is always allowed; the schema grants
ownership implicitly. The "subsequent creations need MANAGE_SERVER or
START_ACTIVITY" rule is documented in the route but not yet enforced —
the role system lands with M13.
### Error surfacing
Errors from Drizzle are caught at the route boundary and the `err.message`
is copied into the response body as `detail`. This is intentionally
verbose in dev (the same string ends up in the in-process logger). In a
real deployment, a future PR swaps this for a stable error code + a
sanitized message, and the full error is shipped to the audit-log table.
## DB schema delta (M10 only)
The only migration required for M10 is the addition of `users.guest_key`:
```ts
// packages/db/src/schema.ts (excerpt)
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
email: text('email').unique(),
displayName: text('display_name').notNull(),
avatarUrl: text('avatar_url'),
locale: text('locale').notNull().default('en'),
isGuest: boolean('is_guest').notNull().default(false),
// NEW in M10: a stable per-guest key, sourced from the cookie's gid.
// Null for non-guest users; unique when present.
guestKey: text('guest_key').unique(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
}, (table) => ({
guestKeyIdx: index('idx_users_guest_key').on(table.guestKey).where(sql`guest_key IS NOT NULL`),
}));
```
The `guestKey` index is partial (`WHERE guest_key IS NOT NULL`) because the
column is null for every real (non-guest) user; the partial index keeps
the unique-constraint lookup cheap even at large row counts.
The `servers` and `memberships` tables were already in the M3 schema — M10
just exercises them for the first time via the API.
## Query helpers
```ts
// packages/db/src/queries/users.ts
findOrCreateGuestUser(db, { guestKey, displayName, locale? })
// Idempotent: insert with ON CONFLICT DO NOTHING, then select.
// Returns the materialized UserRow (with a real uuid), or null if the
// row was deleted between the conflict and the select.
getUserById(db, id)
softDeleteUser(db, id)
```
```ts
// packages/db/src/queries/servers.ts
createServer(db, { name, ownerUserId, slug?, defaultLocale?, isPublic? })
// Inserts the server, then auto-inserts an owner membership in the
// same call (so the server is visible in the owner's list immediately).
getServerById(db, id) // excludes soft-deleted
listServersForUser(db, userId) // joins memberships, orders by recency, limit 100
softDeleteServer(db, id)
```
The queries are intentionally thin: they take a `DbClient` as the first
argument, never read from `process.env`, and never cache anything. This
makes them trivially mockable in route-level tests and re-usable from
the desktop app or a future CLI.
## Web app DB singleton
[`apps/web/lib/db.ts`](../apps/web/lib/db.ts) holds a single Drizzle client
per Node process. The instance is stashed on `globalThis` so it survives
Next.js's hot-reload re-imports (the same trick Prisma's docs recommend).
A test-only `__setDbForTests(client)` hook lets route tests swap in a
mock without going through the real connection string.
```ts
import { getDb } from '@/lib/db';
import { createServer } from '@lobbyforge/db';
const row = await createServer(getDb(), { name, ownerUserId });
```
If `DATABASE_URL` is missing, `getDb()` throws at first call — fail-loud
at startup, not on the first DB-touching request.
## Testing
`apps/web/app/api/servers/__tests__/servers.test.ts` covers the three
endpoints with `vi.mock` for `@lobbyforge/db` and `@/lib/security-headers`:
| Scenario | Expected status |
|---|---:|
| `POST` without cookie | 401 |
| `POST` with a cookie whose `uid` is `null` | 503 |
| `POST` with a name below the 2-char minimum | 400 |
| `POST` happy path | 201, `createServer` called with the caller's `uid` |
| `GET` happy path | 200, server list passed through unchanged |
| `GET` without cookie | 401 |
| `GET /{id}` for an unknown id | 404 |
| `GET /{id}` for a non-owner | 403 |
| `GET /{id}` for the owner | 200 |
The tests use a real signed `lf_guest` cookie (built via
`buildGuestSessionCookie` from `@/lib/guest-session`) so the route's
`readGuestSession` sees the same code path it does in production.
## Out of scope (deferred to later milestones)
- **Membership queries.** `listServersForUser` joins the `memberships` table, but there is no `GET /api/servers/{id}/members` yet. M11.
- **Channel / role / invite endpoints.** M11 + M12 (channels: M11 done; messages: M12 done) per the roadmap. Roles land with M13.
- **Official/self-host creation split.** `POST /api/servers` is enabled only
when `LOBBYFORGE_DEPLOYMENT_MODE=official`. Self-host deployments fail closed
and provision their initial instance through the installer/bootstrap flow.
- **Audit logging.** The route catches errors and returns 500, but doesn't write to the audit log. The shape is in the `withApiSecurity` wrapper, the table lands with M14.
- **Rate limiting.** Production uses Redis atomically; tests and local
development use the in-process fallback unless explicitly configured.
- **Slug uniqueness constraint enforcement at the schema level.** Today, two servers with the same slug will both succeed; the UI dedupes. The unique index is M11.
- **Server icon upload.** `iconUrl` is null; the upload pipeline is M15.