Ctrl K
ComunidadGuías…

docs/SERVERS.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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.