docs/INVITES.md
Rama main · réplica de lectura del repositorio original.
# 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.