Ctrl K
ComunidadGuías…

docs/INVITES.md

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

Archivo original ↗Preguntar a KiBot
# Invites — M14 / Phase 2 community MVP

The invite system is the fifth Phase 2 surface (after M10 servers, M11 channels, M12 messages, M13 roles). It closes the loop on the Phase 2 success criterion — a small group of friends can actually join a server they were invited to.

This document covers:
- The DB schema (inherited from M3, exercised by M14)
- The 7 query helpers in [`packages/db/src/queries/invites.ts`](../packages/db/src/queries/invites.ts)
- The 4 new HTTP endpoints (create / list / revoke / metadata / redeem)
- The `/join/[code]` landing page
- The transactional redeem with `SELECT ... FOR UPDATE`
- The `@everyone` auto-assignment on a successful redeem
- The relationship to the M13 `CREATE_INVITE` permission
- The out-of-scope list (server-side UI, audit log, invite analytics)

## Code format

Invite codes are 12 chars of the **Crockford base32** alphabet:

```
23456789ABCDEFGHJKMNPQRSTVWXYZ
```

Excludes `0`, `1`, `I`, `L`, `O`, `U` — the characters most often misread or misread aloud. With 27 symbols over 12 positions, the keyspace is 27^12 ≈ 1.5 × 10^17. That's enough that a non-targeted scan (60 req/min × 5 years) doesn't crack a code by chance.

The route layer validates the code shape on every entry point with `/^[A-Z2-9]{6,16}$/i` — letters and digits, the same alphabet, with a 6-16 char range (so the storage layer can grow the code length later without a schema migration).

## Query helpers

```ts
// packages/db/src/queries/invites.ts
createInvite(db, { serverId, createdBy, maxUses?, expiresAt? })
getInviteById(db, inviteId)
getInviteByCode(db, code)
listInvitesForServer(db, serverId)            // joins `servers` to filter out soft-deleted
getInviteMetadata(db, code)                   // public projection (no PII)
revokeInvite(db, inviteId)                    // hard delete
redeemInvite(db, code, userId, { note? })     // transactional SELECT FOR UPDATE
```

The approval queue lives in [`packages/db/src/queries/joinRequests.ts`](../packages/db/src/queries/joinRequests.ts)
(`fileJoinRequest`, `autoJoinServer`, `requestToJoinServer`,
`hasFiledThroughInvite`, `listJoinRequestsForServer`, `approveJoinRequest`,
`rejectJoinRequest`, `cancelJoinRequest`, `getOpenJoinRequest`) — see
[Approval queue](#approval-queue).

`redeemInvite` returns a discriminated union:

```ts
type RedeemInviteResult =
  | { ok: true; membershipId: string; serverId: string; roleId: string }
  | { ok: false; error: 'not_found' | 'expired' | 'exhausted' | 'already_member' | 'no_everyone_role' | 'banned' }
  // held for approval (no membership):
  | { ok: false; error: 'pending_approval'; serverId: string; request: JoinRequestRow; created: boolean }
  | { ok: false; error: 'join_rejected'; serverId: string; retryAfter: Date }
  | { ok: false; error: 'join_request_limit'; serverId: string };
```

The error map is a route-layer concern (the route layer maps it to status codes — see "Endpoints" below).

## Endpoints

| Method | Path | Auth | Body | Status | Rate limit |
|---|---|---|---|---|---|
| `GET`    | `/api/servers/{id}/invites`              | `CREATE_INVITE` (own invites) or `MANAGE_SERVER` (all); the owner sees all | — | 200 / 403 / 404 | 60 / min |
| `POST`   | `/api/servers/{id}/invites`              | `CREATE_INVITE` | `{ maxUses?: 1..1000, expiresAt?: ISO8601 }` | 201 / 400 / 403 / 404 | 10 / min |
| `DELETE` | `/api/servers/{id}/invites/{inviteId}`   | `CREATE_INVITE` (own invite) or `MANAGE_SERVER` (any); revoking another member's invite while timed out is 403 `actor_timed_out` | — | 200 / 400 / 403 / 404 | 10 / min |
| `GET`    | `/api/invites/{code}`                    | Public  | — | 200 / 400 / 404 | 60 / min |
| `POST`   | `/api/invites/{code}/redeem`             | Session + `uid` | `{ note?: string ≤ 500 }` (optional; only used under an approval policy) | 201 / 202 / 400 / 401 / 403 / 409 / 429 / 503 | 10 / min |

The body of `POST /api/servers/{id}/invites` is validated by:

```ts
z.object({
  maxUses: z.number().int().positive().max(1000).optional(),
  expiresAt: z.string().datetime().optional(),
});
```

The `expiresAt` is converted to a `Date` on the server. If the string parses to `NaN`, the route returns 400 (defensive — Zod's `.datetime()` should already have caught malformed inputs).

`GET /api/invites/{code}` returns the **public** projection:

```json
{
  "invite": {
    "code": "ABC23D9P45Q7",
    "serverId": "...",
    "serverName": "My friends",
    "expiresAt": "2026-06-15T12:00:00Z",
    "currentUses": 3,
    "maxUses": 25,
    "isExpired": false,
    "isExhausted": false
  }
}
```

No PII (no `createdBy`, no use list) — that's what the authenticated `GET /api/servers/{id}/invites` is for. `Cache-Control: no-store` because `currentUses` changes on every redeem.

`POST /api/invites/{code}/redeem` maps `redeemInvite` errors to status codes:

| Error             | Status | Body                                  |
|-------------------|--------|---------------------------------------|
| `not_found`       | 404    | `{ "error": "Invite not found" }`     |
| `already_member`  | 409    | `{ "error": "You are already a member of this server" }` |
| `expired`         | 410    | `{ "error": "Invite has expired" }`   |
| `exhausted`       | 410    | `{ "error": "Invite has reached its use limit" }` |
| `no_everyone_role`| 500    | `{ "error": "Server is missing the @everyone role. This is a server-side bug." }` |
| `pending_approval` | **202** | `{ "status": "pending_approval", "request": { "id", "serverId", "createdAt" } }` — the server's access policy holds newcomers for a moderator, so a join request was filed (or the user's pending one returned). See [Approval queue](#approval-queue). |
| `join_rejected` | 403 | `{ "error": "...", "code": "join_rejected", "retryAfter": ISO8601 }` — a moderator rejected the user's last request; they cannot ask again until `retryAfter`. |
| `join_request_limit` | 429 | `{ "error": "...", "code": "join_request_limit" }` — too many requests to this server in 24 hours. |

A redeem (and the `/lobby` auto-join) starts the new membership with the
timeout / server mute stored in `server_member_sanctions` (migration 0040),
so leaving and rejoining does not lift a sanction (security-review
AUTHZ-002). A timed-out member cannot create invites (403, same body as the
message routes).

(`not_found`, `expired`, `exhausted` and `banned` all answer 403 with the
same `Invite is unavailable` / `You are banned` bodies — the table above is
the query's vocabulary, the route keeps the outward answers uniform.)

On success (201), it returns:

```json
{
  "membership": {
    "serverId": "...",
    "userId": "...",
    "roleId": "..."    // the @everyone role id
  }
}
```

## Approval queue

A server whose access policy holds newcomers for a moderator —
`requireApprovalForFirstJoin`, `joinPolicy: public_with_approval` or
`accountLinking: require_admin_approval_first_join`
(`accessPolicyRequiresApproval`) — admits nobody without a decision. The
owner is never held out of their own server; existing members are never
re-checked.

**Filing a request.** Two explicit, same-origin POSTs file a row in
`server_join_requests` (migration 0043) instead of a membership and answer
**202 `pending_approval`**:

- `POST /api/invites/{code}/redeem` (source `invite`). The join page offers
  the optional note when `GET /api/invites/{code}` says
  `requiresApproval: true`.
- `POST /api/servers/{id}/join-requests/mine` (source `auto_join`) — the
  lobby's **Ask to join** button. Without an invite only the community the
  `/lobby` auto-join serves can be asked: the instance's first community,
  for its owner or, on an open-registration instance, anyone who could have
  registered into it (guests only while guest access is on —
  `resolveAutoJoinServerId` in `apps/web/lib/lobby-auto-join.ts`, shared
  with the lobby). Any other server answers 403 `invite_required`.

Both take an optional `{ note }` (≤ 500 characters), shown to the
moderators, and follow the same rules below.

**The lobby never files a request on a page load.** Opening `/lobby`
(`autoJoinServer`) still joins a community that needs no approval, but
under an approval policy it only reports where the user stands — a GET,
possibly a cross-site top-level link, must not put anyone (a free guest
included) in the queue. The "community unavailable" page then shows an
**Ask to join** form (an optional note + button) when the user has no
request, "Waiting for approval" with **Check again** and **Withdraw
request** while one is pending (also for a request an invite filed on an
invite-only instance), or "declined" during a cooldown. Where the user may
not auto-join and has no request, it says to ask a moderator for an
invite (previously "could not load the community data"). After either
button the server-rendered page is refreshed (`router.refresh()`), so it
always shows what the server knows.

- **One pending request per (server, user)** — a partial unique index
  (`server_join_requests_one_pending_unique`, SQL-only). Redeeming again,
  or pressing Ask to join again, returns the same pending request.
- **Bans win.** A banned user gets 403 `banned` and no request; banning a
  user rejects their pending request (`banUser`); approval re-checks the
  ban and rejects instead of admitting.
- **Rejection cooldown.** After a moderator's rejection the user cannot
  ask again for 7 days (`JOIN_REQUEST_REJECTION_COOLDOWN_MS`): 403
  `join_rejected` with `retryAfter`, and the lobby says the request was
  declined.
- **No cooldown from a ban.** A rejection written *by* a ban (`banUser`,
  or an approval that found the user banned) is flagged
  `rejected_by_ban` and starts no cooldown (`getOpenJoinRequest` ignores
  it). While the ban lasts it keeps the user out anyway; once it is lifted
  — or a timed ban expires — the user may ask again at once instead of
  waiting out 7 days for a decision nobody made about their request. The
  flag needs a column because the ban row is deleted on unban, so nothing
  else could tell the two rejections apart afterwards.
- **Flood limits.** At most 5 requests per (server, user) per 24 hours,
  whatever became of them (429 `join_request_limit`; a cancel → re-request
  loop stops there), on top of the routes' per-address rate limits (10 /
  min for the redeem and for Ask to join).
- **Registration** still refuses an approval policy (403): a local account
  is not created for a join a moderator may reject. Guests and existing
  accounts ask through an invite or the lobby.

**Invite uses — one per person and code.** A user's *first* request
through a code takes one use of the invite, inside the same transaction
that locks the invite row. Nothing else takes one: not a repeat redeem of
a pending request, not the approval, and not a new request by the same
user through the same code after they withdrew or after a rejection's
cooldown (`hasFiledThroughInvite`: any earlier request of theirs with this
`invite_code` on this server, whatever its status). Such a re-request also
skips the "exhausted" check — the user already holds a use, possibly the
code's last — but not the expiry check. The user's own pending request (or
a rejection in its cooldown) is returned *before* the expiry / use checks
too, so it never locks them out of seeing it.

Why this and not a refund on withdraw: counting uses at approval would let
a `maxUses: 1` code posted publicly put any number of strangers in the
queue, so uses are taken when the request is filed. A refund on withdraw
would hand the freed use to the next stranger — file, withdraw, next
account — and the code would no longer bound how many people it puts in
front of the moderators. Charging once per person keeps `maxUses` meaning
"how many people may ask through this code" and stops the burn (file →
withdraw → file used to take up to 5 uses a day from one account). A
rejected or cancelled request never gives its use back.

**Deciding.** Moderators with **KICK_MEMBERS or MANAGE_SERVER** (see
[ROLES.md](./ROLES.md#join-requests)) review the queue in Community
Settings → Members (the "Join requests" section):

| Method | Path | Who | Body | Status | Rate limit |
|---|---|---|---|---|---|
| `GET` | `/api/servers/{id}/join-requests?status=pending\|all&limit=&offset=` | KICK_MEMBERS or MANAGE_SERVER | — | 200 / 400 / 401 / 403 / 404 | 60 / min |
| `POST` | `/api/servers/{id}/join-requests/{requestId}` | KICK_MEMBERS or MANAGE_SERVER, not timed out (403 `actor_timed_out`) | `{ action: 'approve' \| 'reject' }` | 200 / 400 / 403 / 404 / 409 | 30 / min |
| `GET` | `/api/servers/{id}/join-requests/mine` | the requester | — | 200 / 401 / 404 | 60 / min |
| `POST` | `/api/servers/{id}/join-requests/mine` | a signed-in non-member the lobby auto-join serves | `{ note?: string ≤ 500 }` (optional) | 202 / 400 / 401 / 403 / 404 / 409 / 429 | 10 / min |
| `DELETE` | `/api/servers/{id}/join-requests/mine` | the requester | — | 200 / 401 / 404 | 10 / min |

A server id or request id that is not a UUID answers **404** on every one
of these routes, before any database call (Postgres would throw on the
cast → 500).

- The list shows pending requests first, oldest first (`status=all`
  appends decided ones, newest decision first), with `pendingCount` and
  `nextOffset` (`limit` ≤ 100). Each row carries the requester's name,
  guest flag, account age, source (`invite` / `auto_join`), the note, and
  who created the invite used. The invite code itself is shown only to
  MANAGE_SERVER holders (beta-review F7). A pending request whose user
  joined some other way meanwhile is left out.
- **Approve** creates the membership exactly like the auto-join
  (`createMembershipWithEveryone`: `@everyone`, plus the timeout / server
  mute stored in `server_member_sanctions`), in one transaction with the
  request row locked, so two moderators cannot both decide it. It greets
  the member (Welcome Bot, `notifyMemberJoined`) and audits
  `member.join_approved`. A plain join publishes no realtime event, so
  neither does an approval. 409 `not_pending` when the request was already
  decided; 409 `banned` when the user was banned meanwhile.
- **Reject** audits `member.join_rejected` and starts the cooldown.
- **Scoped to the URL's server.** The request row is looked up by id AND
  the server in the URL — the server whose permissions were checked — so a
  moderator of server A who sends server B's request id gets 404 and
  nothing changes (proven against real Postgres in
  `join-requests.integration.test.ts`).
- **Audit metadata** is `{ requestId, source }` (plus `membershipId` on
  approval) — never the invite code. Moderators who read the audit log may
  lack MANAGE_SERVER, and invite codes are for MANAGE_SERVER holders only
  (beta-review F7); the request id already leads a manager to the code in
  the join-request list.
- **Invite audit rows** follow the same rule (security-review AUTHZ-010):
  `invite.create` logs `{ maxUses, expiresAt }` with the invite's id as
  `targetId`, and `invite.redeem` logs `{ inviteId, roleId }` — no `code`.
  Rows written before that still carry `code`; the audit readers
  (`GET /api/servers/{id}/audit-logs` and Community Settings → Audit Log,
  `lib/audit-log-view.ts`) drop it for anyone without MANAGE_SERVER.
- **Mine** returns only the caller's own pending request (or a moderator's
  rejection in its cooldown, with `retryAfter`) — no membership needed, no
  note or moderator echoed back. `POST` is the lobby's Ask to join (see
  "Filing a request": 403 `banned` / `join_rejected` / `invite_required`,
  409 `already_member` / `approval_not_required`, 429
  `join_request_limit`). `DELETE` withdraws a pending request.

## The transactional redeem

`redeemInvite` opens a Drizzle transaction:

```ts
db.transaction(async (tx) => {
  const [invite] = await tx.select().from(invites)
    .where(eq(invites.code, code)).for('update');
  if (!invite) return { ok: false, error: 'not_found' };
  if (invite.expiresAt && invite.expiresAt < now) return { ok: false, error: 'expired' };
  if (invite.maxUses !== null && invite.currentUses >= invite.maxUses) {
    return { ok: false, error: 'exhausted';
  }
  // Look up the @everyone role.
  const [everyone] = await tx.select().from(roles)
    .where(and(eq(roles.serverId, invite.serverId), eq(roles.name, EVERYONE_ROLE_NAME)));
  if (!everyone) return { ok: false, error: 'no_everyone_role' };

  // Idempotent: if the user is already a member, return the existing row.
  const [existing] = await tx.select().from(memberships)
    .where(and(eq(memberships.userId, userId), eq(memberships.serverId, invite.serverId)));
  if (existing) {
    return { ok: true, membershipId: existing.id, serverId: invite.serverId, roleId: existing.roleId };
  }

  const [m] = await tx.insert(memberships).values({
    serverId: invite.serverId, userId, roleId: everyone.id,
  }).returning();
  await tx.update(invites).set({ currentUses: sql`${invites.currentUses} + 1` })
    .where(eq(invites.id, invite.id));
  return { ok: true, membershipId: m.id, serverId: invite.serverId, roleId: everyone.id };
});
```

The `SELECT ... FOR UPDATE` (Drizzle's `.for('update')`) prevents a concurrent over-redeem past `maxUses`. Without it, two simultaneous redemptions could each see `currentUses = maxUses - 1`, both insert memberships, and leave `currentUses = maxUses + 1`.

The `@everyone` lookup is the reason the `no_everyone_role` error exists — it can only happen if the role row was deleted by a hand-rolled SQL call (the API rejects deleting `@everyone` with a 400).

The redeem is idempotent on the membership side: re-running with the same `(userId, code)` is a no-op for the membership insert, but it still increments `currentUses`. That's deliberate — the invite's use count is the audit trail, not the membership.

## The `/join/[code]` page

`apps/web/app/join/[code]/page.tsx` is a client component that:

1. On mount, fetches `GET /api/invites/{code}` to render the server name + invite state. A 404 renders "This invite code is unknown or has been revoked".
2. Probes `GET /api/auth/guest` (the M9 "who am I" endpoint) so a returning visitor skips the sign-in step.
3. Step 1 of the UI: "Sign in as guest" — `POST /api/auth/guest` (idempotent re-bind).
4. Step 2 of the UI: "Accept invite" — `POST /api/invites/{code}/redeem`. The 401/409/410/404 status codes each have a tailored message.
5. On success, renders "You are now a member of <ServerName>" with a placeholder link to `/servers/{serverId}` (the real server-home page is M15 UI).

The page is intentionally a thin shell — the rest of the M15 UI is a separate milestone.

## Relationship to M13's `CREATE_INVITE`

The `@everyone` default permissions include `create_invite`. That means **any member** of a server can create an invite — the M13 design is "creating an invite is a low-trust action; revoking it is high-trust". `CREATE_INVITE` lists and revokes only the caller's own invites; listing or revoking everyone's (`GET /api/servers/{id}/invites`, `DELETE /api/servers/{id}/invites/{inviteId}`) needs `MANAGE_SERVER` (beta-review F7). Either permission gets a caller into these routes, so a manager whose roles do not carry `CREATE_INVITE` still manages every invite.

The M15 server-home UI gives a "create invite" button to every member and a "revoke" button only to admins. The M14 backend already does the right thing.

## Cross-platform

- The codes are generated with `crypto.randomBytes(8)` from Node's stdlib — uniform across OSes.
- The route handlers are Node-only (`runtime = 'nodejs'`).
- The transaction + `SELECT FOR UPDATE` relies on Postgres, which the `@lobbyforge/db` package already targets.

## Tests

The query helpers are unit-testable in isolation; the route layer is tested through the integration test in the M14 milestone (the plan called for ~9-10 tests but the M13 + M14 test expansion landed at 28 — 15 roles + 13 members). The invite route tests are a M15 follow-up.

## Out of scope

- **Server-home UI for invite management.** The "create / list / revoke" buttons live in `/servers/{id}`, which is M15.
- **Audit log entries.** Every M14 mutation (create / revoke / redeem) should land a row in `audit_logs`. The wiring is M15.
- **Invite analytics.** "How many of my invites converted?" is a dashboard question; the data is already in the table.
- **Custom invite URLs.** Today the code is the URL. A future iteration can let the server owner pick a vanity slug.
- **Per-channel invites.** Today every invite grants `@everyone`. A per-channel invite (Discord's "invite to a specific channel") needs an `invite_channels` table — M15+.
- **Invite email.** Sending the code in an email requires a transactional email service. The link is a normal `https://…/join/{code}` URL today, so copying it into a chat works.
- **Disposable one-time invites.** `maxUses: 1` already implements this; the UI for it (a "burn after first use" toggle) is M15.
- **Invite expiration warning emails.** A pre-expiry reminder would land in the same transactional email system; out of scope until that lands.