28 KiB
REST API Reference
Base: /api. Auth via Authorization: Bearer <jwt>. All responses JSON.
Source files: packages/server/src/routes/*.ts
Auth (routes/auth.ts) — public, rate-limited
POST /auth/register { username, password, displayName?, avatarColor?, homeInstance?, homeUserId?, inviteToken? } → { token, user }
GET /auth/check-username ?username= → { available, reason? }
GET /auth/check-invite ?token= → CheckInviteResponse
POST /auth/login { username, password } → { token, user }
POST /auth/register gating — branches on whether homeInstance is set:
- Federated path (
homeInstanceset): gated solely byinstance_settings.federatedRegistrationOpen.inviteTokenis ignored entirely (not validated, not consumed). 403Federated registration is closed on this instancewhen closed. Existing federated stubs (relay-created,passwordHash = '!federation-replicated') upgrade in place — login is never blocked by this gate. - Local path (no
homeInstance):- When
registrationOpenis true:inviteTokenis silently ignored (no row touched, nousedCountincrement). - When
registrationOpenis false:inviteTokenis required. The token is pre-validated, then the user INSERT +usedCountincrement +invite_redemptionsrow INSERT all run in a single transaction (inviteService.redeemInvite). 403Registration is closed. An invite is required.(no token) orInvalid or expired invite(token rejected at any stage, including a concurrent-redemption race re-check inside the transaction).
- When
GET /auth/check-invite — public, rate-limited 30/min/IP. Always returns 200; the body discriminates:
type CheckInviteResponse =
| { valid: true; name: string } // active token; name surfaces for UX
| { valid: false; reason: 'expired' | 'exhausted' | 'invalid' };
revoked, malformed (non-22-char-base64url), and not-in-DB tokens all collapse to 'invalid' (enumeration shield). name is returned only in the valid case.
Users (routes/users.ts) — auth required
GET /users/@me → { user }
PATCH /users/@me { displayName?, avatar?, banner?, accentColor?, avatarColor?,
bio?, customStatus?, status?, replicatedInstances?, homeUserId?,
profileUpdatedAt?, discoverable?, showActivity? } → { user }
POST /users/@me/verify-password { password } → { valid }
POST /users/@me/change-password { currentPassword?, newPassword } → { token }
DELETE /users/@me { password, username } → { success }
PUT /users/@me/space-layout { items, folders, updatedAt? } → { items, folders, updatedAt }
GET /users/@me/federation-registry → { registry: FederationRegistryEntry[], updatedAt: number }
PUT /users/@me/federation-registry { registry, updatedAt } → { ok: true, updatedAt } (409 if not newer)
GET /users/:id → { user }
GET /users/:id/mutuals ?homeUserId= → { mutualFriends[], mutualSpaces[] }
Write protection: If the authenticated user is a replicated user (homeInstance is set), the following fields are rejected with 403: displayName, avatar, banner, accentColor, avatarColor, bio. These fields are managed by the home instance via S2S relay.
Spaces (routes/spaces.ts) — auth required
GET /spaces → { spaces[] }
POST /spaces { name, icon?, description? } → { space }
GET /spaces/:id → { space, channels[], members[], roles[] }
PATCH /spaces/:id { name?, icon?, banner?, description?, visibility?, avatarColor? } → { space } [MANAGE_SPACE]
DELETE /spaces/:id → { success } [owner]
POST /spaces/:id/invite → { inviteCode } [CREATE_INVITE]
POST /spaces/:id/join { inviteCode } → { space }
POST /spaces/join { inviteCode } → { space }
GET /spaces/invite/:code/preview → invite preview
PATCH /spaces/:id/transfer-ownership { newOwnerId } → { space } [owner]
Members
GET /spaces/:id/members → { members[] }
PATCH /spaces/:id/members/:uid { nickname?, roles? } → { member } [MANAGE_ROLES]
DELETE /spaces/:id/members/:uid → { success } [KICK_MEMBERS|self]
Bans
GET /spaces/:id/bans → { bans[] } [BAN_MEMBERS]
POST /spaces/:id/bans { userId, reason? } → { success } [BAN_MEMBERS]
DELETE /spaces/:id/bans/:uid → { success } [BAN_MEMBERS]
Roles
POST /spaces/:id/roles { name, color?, permissions? } → { role } [MANAGE_ROLES]
PATCH /spaces/:id/roles/:rid { name?, color?, position?, permissions? } → { role } [MANAGE_ROLES]
DELETE /spaces/:id/roles/:rid → { success } [MANAGE_ROLES]
POST /spaces/:id/members/:uid/roles { roleId } → { success } [MANAGE_ROLES]
DELETE /spaces/:id/members/:uid/roles/:rid → { success } [MANAGE_ROLES]
Channels (routes/channels.ts) — auth required
GET /spaces/:id/channels → { channels[] } [VIEW_CHANNEL]
POST /spaces/:id/channels { name, type?, topic?, categoryId? } → { channel } [MANAGE_CHANNELS]
PATCH /channels/:id { name?, type?, topic?, categoryId? } → { channel } [MANAGE_CHANNELS]
DELETE /channels/:id → { success } [MANAGE_CHANNELS]
PATCH /spaces/:id/channels/reorder { order } → reordered [MANAGE_CHANNELS]
Channel Overrides
GET /channels/:id/overrides → { overrides[] } [MANAGE_CHANNELS]
PUT /channels/:id/overrides { targetType, targetId, permissions } → { override } [MANAGE_CHANNELS]
DELETE /channels/:id/overrides/:targetType/:targetId → { success } [MANAGE_CHANNELS]
Categories
POST /spaces/:id/categories { name } → { category } [MANAGE_CHANNELS]
PATCH /categories/:id { name?, position? } → { category } [MANAGE_CHANNELS]
DELETE /categories/:id → { success } [MANAGE_CHANNELS]
GET /categories/:id/overrides → { overrides[] } [MANAGE_ROLES]
PUT /categories/:id/overrides { targetType, targetId, permissions } → { success } [MANAGE_ROLES]
DELETE /categories/:id/overrides/:tt/:tid → { success } [MANAGE_ROLES]
Messages (routes/messages.ts) — auth required
GET /channels/:id/messages ?before=&limit=50 → { messages[] } [VIEW_CHANNEL+READ_MESSAGE_HISTORY]
POST /channels/:id/messages { content, attachments?, replyToId? } → { message } [SEND_MESSAGES, +ATTACH_FILES]
PATCH /messages/:id { content } → { message } [author]
DELETE /messages/:id → { success } [author|MANAGE_MESSAGES]
DMs (routes/dm.ts) — auth required
POST /dm { targetUserId, targetUsername? } → { dmChannel }
POST /dm/group { name, memberUserIds[] } → { dmChannel }
PATCH /dm/:id { name?, icon? } → { id, name, icon, metadataUpdatedAt } [owner; group only]
DELETE /dm/:id → { success } (soft-close)
POST /dm/:id/members { userIds[] } → { dmChannel } [owner, max 10]
DELETE /dm/:id/members → { success } (leave)
DELETE /dm/:id/members/:targetUserId → { success } [owner kick; cannot self-kick; group only]
POST /dm/:id/transfer { newOwnerId } → { success } [owner; member must be in channel; not self]
GET /dm/:id/messages ?before=&limit=50 → { messages[] }
POST /dm/:id/messages { content, attachments?, replyToId? } → { message }
PATCH /dm/messages/:id { content } → { message } [author]
DELETE /dm/messages/:id → { success } [author]
PATCH /dm/:id — Owner-only update of a group DM's name and icon. Either field may be omitted (no-op), null (clear), or set. Empty/whitespace name collapses to null. icon accepts a bare attachment filename owned by the caller (image/*, ≤ GROUP_DM_ICON_MAX_BYTES) or an absolute http(s) URL. No-op short-circuit when nothing actually changes — emits no system message and no federation relay. See docs/systems/dm-system.md "Group Metadata Update" for the full transaction, federation relay, and icon URL round-trip rules.
DELETE /dm/:id/members/:targetUserId — Owner kicks a member from a group DM. Reuses the leave path with reason: 'kick'; evicts the target from the DM voice room first. Sends dm_channel_closed to the kicked user. Receivers enforce sourceInstance === ownerHomeInstance; non-owner kicks reject as unauthorized_source.
POST /dm/:id/transfer — Owner transfers ownership to another current member without leaving. Updates ownerId, ownerHomeUserId, ownerHomeInstance; inserts an owner_changed system message; broadcasts dm_owner_updated; queues an ownership_transfer outbox event. Reuses the existing receiver path (processOwnershipTransferEvent) with no protocol changes.
Social (routes/social.ts) — auth required
GET /social/friends → { friends[] }
GET /social/requests → { requests[] }
POST /social/requests { username } → { success, requestId }
PATCH /social/requests/:id { status: 'accepted'|'declined' } → { request }
DELETE /social/requests/:id → { success } (cancel, sender-only)
DELETE /social/friends/:id → { success }
GET /social/discover ?q=&limit=&offset= → { users[], total }
GET /social/search ?q= → { users[] }
POST /api/social/requests — routing & error codes
body.username may be bare (local), bare@<own host> (also routed local — server normalizes), or bare@<remote host> (federated branch). The client sends the trimmed handle verbatim; all parsing, routing, peering, and remote lookup are server-side.
| HTTP | error code | When |
|---|---|---|
| 200 | (success, idempotent) | Same-direction pending request already exists; returns existing requestId |
| 201 | (success, created) | New friend request created |
| 400 | username_required |
Missing/empty/non-string username |
| 400 | cannot_friend_self |
Looked-up identity matches sender |
| 400 | invalid_target_domain |
Scheme resolution failed (e.g., non-localhost HTTP target when our scheme is HTTPS) |
| 403 | peer_rejected |
Remote instance has rejected federation; admin must intervene |
| 403 | not_authoritative_for_sender |
Caller is a federated (replicated) user; should not have reached here |
| 404 | user_not_found |
Remote lookup returned 404 (no such user, or tombstoned) |
| 409 | already_friends |
Friendship row already exists |
| 409 | peer_pending_approval |
Remote admin needs to approve the peering relationship |
| 409 | peer_pending_local_admin |
Local instance has autoAcceptPeering=0 and the user attempted to friend-add a never-peered remote target. The user's own admin must approve before any traffic reaches the wire. Distinct from peer_pending_approval (remote admin must approve). See federation.md → Outbound Peering Gate. |
| 409 | peer_pending |
Peer handshake in flight |
| 409 | incoming_request_exists |
Opposite-direction pending request exists; response includes requestId for deep-link |
| 429 | lookup_rate_limited |
Remote /users/lookup returned 429; Retry-After header forwarded |
| 503 | peer_unreachable |
Remote instance unreachable (network/timeout/lookup-unreachable) |
Search (routes/search.ts) — auth required
GET /channels/:id/search ?q=&from=&has=&before=&after=&offset=&limit= → { results[], totalCount } [VIEW_CHANNEL]
GET /channels/:id/messages/around ?messageId=&limit= → { messages[] }
GET /dm/:id/search ?q=&from=&has=&before=&after=&offset=&limit= → { results[], totalCount }
GET /dm/:id/messages/around ?messageId=&limit= → { messages[] }
has: file|image|link
Explore (routes/explore.ts) — auth required
GET /spaces/explore ?q=&limit=&offset= → { spaces[], total, totalAll, discoveryEnabled }
POST /spaces/:id/public-join → { space }
POST /spaces/:id/request-join { message? } → { request }
GET /spaces/:id/join-requests ?status= → { requests[] } [MANAGE_SPACE]
PATCH /spaces/:id/join-requests/:rid { action } → { request } [MANAGE_SPACE]
GET /users/@me/join-requests ?status= → { requests[] }
Uploads (routes/files.ts, routes/uploads.ts)
Tus Upload Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /api/files/ |
JWT | Create resumable upload. Returns Location (upload URL) and Upload-Expires. |
| HEAD | /api/files/:uploadId |
JWT (ownership) | Probe Upload-Offset for resume. |
| PATCH | /api/files/:uploadId |
JWT (ownership) | Append bytes at offset. |
| DELETE | /api/files/:uploadId |
JWT (ownership) | Abort. |
The final PATCH that completes an upload returns the Attachment JSON in its response body. See docs/systems/uploads.md for the full pipeline (PRE_CREATE / PRE_PATCH / POST_FINISH hooks, storage layout, janitor).
File Serving
GET /uploads/:filename (public, supports Range) → file stream
GIF (routes/gif.ts) — auth required
GET /gif/enabled → { enabled }
GET /gif/trending ?limit=&pos= → { results[], next }
GET /gif/search ?q=&limit=&pos= → { results[], next }
Backend: Klipy API (requires gifApiKey in instance_settings)
Voice (routes/livekit.ts) — auth required
POST /livekit/token { channelId | dmChannelId } → { token, url }
Permissions checked: CONNECT, SPEAK, STREAM (space channels). DM calls: always full grants.
Instance (routes/instance.ts) — public
GET /instance/info → { name, version, registrationOpen, federatedRegistrationOpen }
federatedRegistrationOpen is a UX hint consumed by the Connections add-instance pre-flight (see client-federation.md). The 403 from POST /auth/register remains the security boundary.
Settings (routes/settings.ts)
GET /settings/streaming (auth) → { streamingLimits }
PATCH /settings/streaming (admin) → { streamingLimits }
GET /settings/instance (admin) → { instanceName, registrationOpen, federatedRegistrationOpen, discoveryEnabled, ... }
PATCH /settings/instance (admin) { instanceName?, registrationOpen?, federatedRegistrationOpen?,
discoveryEnabled?, gifApiKey?, maxUploadSizeMb?,
federationRelayEnabled?, federationRelayTtlDays? } → { settings }
registrationOpen and federatedRegistrationOpen are independent toggles. PATCH validates federatedRegistrationOpen is boolean if provided; rejects 400 otherwise. registrationOpen is stored as a nullable column (null = fall back to config.registrationOpen env default); federatedRegistrationOpen is NOT NULL with default 1.
Admin (routes/admin.ts) — admin required
GET /admin/storage/stats → StorageStats
GET /admin/storage/orphans → { orphans[] }
POST /admin/storage/cleanup { dryRun? } → CleanupResult
POST /admin/storage/cleanup-media { maxAgeDays, dryRun? } → CleanupResult
GET /admin/users ?q=&page=&pageSize=&showDeleted=&homeInstance=&role=&joinedAfter=&joinedBefore=&sort= → AdminUserListResponse
GET /admin/users/instances → distinct home instance domains
PATCH /admin/users/:id/role { isAdmin } → AdminUser
POST /admin/users/:id/reset-password → { temporaryPassword }
DELETE /admin/users/:id → { success }
Admin: Invite Management (routes/invites.ts) — admin required
All endpoints sit behind [authenticate, requireAdmin]. Mutating endpoints wrap their read-modify-write in a SQLite transaction with an in-txn re-fetch + status re-derive; any state mismatch returns 409. Service layer: packages/server/src/utils/inviteService.ts (InviteValidationError → 400, InviteNotFoundError → 404, InviteStateConflictError → 409).
POST /admin/invites { name, maxUses, expiresAt } → InviteLinkSummary (201)
GET /admin/invites ?status=active|archived (default: active) → { invites: InviteLinkSummary[] }
PATCH /admin/invites/:id { name?, maxUses?, expiresAt? } → InviteLinkSummary
POST /admin/invites/:id/revoke → { invite: InviteLinkSummary }
POST /admin/invites/:id/reinstate { maxUses?, expiresAt? } → { invite: InviteLinkSummary, tokenRotated: boolean }
DELETE /admin/invites/:id → { success: true }
GET /admin/invites/:id/redemptions → { redemptions: InviteRedemption[] }
POST /admin/invites — name 1-64 chars trimmed; maxUses null (unlimited) or positive integer; expiresAt null (never) or epoch ms strictly greater than Date.now(). 400 on shape violation.
GET /admin/invites?status= — active returns rows whose derived status is active; archived returns expired | exhausted | revoked. Sort createdAt DESC. Joins users to surface createdByUsername ('Deleted User' if creator's isDeleted = 1).
PATCH /admin/invites/:id — partial body. 400 if maxUses is a positive integer less than the current usedCount (would retroactively exhaust — admin should revoke instead). 409 if the invite is currently revoked (status conflict — reinstate first).
POST /admin/invites/:id/revoke — sets revokedAt = Date.now(). 409 if already revoked.
POST /admin/invites/:id/reinstate — branches on the row's pre-reinstate derived status:
- Path A — was
revoked: rotates the token (crypto.randomBytes(16).toString('base64url')), clearsrevokedAt, applies any providedmaxUses/expiresAtoverrides. Response includestokenRotated: true. 400 if the resulting row would still derive non-active(caller must bump enough). - Path B — was
expiredorexhausted: token preserved. Applies overrides. ResponsetokenRotated: false. 400 same rule. - Path C — already
active: 409 "Invite is already active." Pure no-op rejection.
DELETE /admin/invites/:id — hard-delete. CASCADE removes all invite_redemptions rows for this invite. Allowed in any status. 404 if not found.
GET /admin/invites/:id/redemptions — sort redeemedAt DESC. Each row includes the registration-moment snapshot (registrantUsername) plus the live joined state (currentUsername / isDeleted) so the UI can render alice (now Deleted User) for renamed/tombstoned users.
type InviteLinkSummary = {
id: string;
token: string;
name: string;
status: 'active' | 'expired' | 'exhausted' | 'revoked'; // derived
maxUses: number | null;
usedCount: number;
expiresAt: number | null;
revokedAt: number | null;
createdBy: string;
createdByUsername: string | null; // 'Deleted User' if creator tombstoned
createdAt: number;
lastRedeemedAt: number | null; // epoch ms of most recent redemption; null when usedCount = 0
url: string; // server-built `https://<host>/register?invite=<token>` — clients MUST NOT assemble
};
type InviteRedemption = {
id: string;
userId: string | null; // null only on hard-delete (defensive — tombstone keeps row)
registrantUsername: string; // snapshot at registration moment
currentUsername: string | null;
isDeleted: boolean;
redeemedAt: number;
};
Federation (routes/federation.ts)
POST /federation/peer/initiate (admin) { remoteOrigin } → peer created
POST /federation/peer/accept (public, IP rate-limited 10/min) { sourceOrigin, challenge, hmacSecret, instanceName?, approvalToken? } → accepted (200) | queued (202 + { approvalToken })
GET /federation/peers (admin) → { peers[] } (no secrets)
DELETE /federation/peers/:id (admin) → { success } + outbox cleanup
POST /federation/relay (HMAC-signed S2S) FederationRelayRequest → { accepted[], rejected[] }
POST /federation/sync (HMAC-signed S2S) { sinceTimestamp, limit?, dmChannelId?, federatedId?, contextType? } → { events[], hasMore, checkpoint }
POST /federation/users/lookup (HMAC-signed S2S, rate-limited 60/min/peer) { username } → { found, user? }
POST /api/federation/peer/accept — public, IP-rate-limited. Optional approvalToken (64-hex) on the request body proves mutual admin approval; required to promote an awaiting_approval row to active when the receiver has autoAcceptPeering=0. The receiver returns it in the 202 body when queueing the request for admin review ({ queued: true, message, approvalToken }); the initiator stores it and the receiver's /approve later forwards it back. See federation.md §1 "Approval Token Verification" for the full lifecycle and threat model.
POST /api/federation/users/lookup — HMAC-authenticated S2S endpoint. Resolves a username on this instance to its canonical (homeUserId, profile snapshot). Used by the cross-instance friend-add flow on the sender's home server before queuing a friend_request_create event. Responds to native, non-deleted users only; ignores discoverable. Returns { found: false, code: 'user_not_found' } for stubs, tombstoned users, or unknown handles. See federation.md §1 "S2S User Lookup" for the full contract.
Federation Peering Approval Queue
Inbound + outbound peering approval queue (autoAcceptPeering=0). See federation.md → Peer Approval Queue and federation.md → Outbound Peering Gate.
GET /federation/approval-requests (admin) → { requests: ApprovalRequestSummary[] }
POST /federation/approval-requests/:id/approve (admin) → { success, peerStatus?, peer? }
POST /federation/approval-requests/:id/deny (admin) → { success }
ApprovalRequestSummary shape:
type ApprovalRequestSummary = {
id: string;
direction: 'inbound' | 'outbound';
origin: string;
instanceName: string | null;
requestedAt: number;
expiresAt: number;
// Outbound rows ONLY — inbound rows OMIT this field entirely (it is absent, not null and not []).
subscribers?: ApprovalRequestSubscriberSummary[];
};
type ApprovalRequestSubscriberSummary = {
id: string;
userId: string;
username: string;
triggerReason: 'friend_add' | 'space_join' | 'direct_message';
triggerTarget: string;
createdAt: number;
};
POST /approval-requests/:id/approve — direction-branched.
- Inbound — existing behavior preserved verbatim (creates / upserts a local
federation_peersrow with statuspending, sends/peer/acceptto origin forwarding the storedapprovalToken, deletes the queue row regardless of whether the result isactive(200) orawaiting_approval(202)). - Outbound — generates a fresh HMAC, sends
/peer/acceptto the origin (no token; we are the initiator).- On 200 → peer becomes
active.onPeerActivatedruns: fans outkind='approved'notifications to subscribers and cascade-deletes the queue row. The handler does NOT duplicate this cleanup. - On 202 → peer transitions to
awaiting_approval, captures the returnedapprovalToken, and the queue row + subscribers are LEFT INTACT for the eventual remote-admin approval.onPeerActivatedis NOT called yet. - On 4xx/5xx/network → the peer row is cleaned up; the queue row is LEFT INTACT so the admin can retry. Response status mirrors the wire failure (
502/503/504).
- On 200 → peer becomes
- Response body:
{ success, peerStatus?: 'active' | 'awaiting_approval', peer? }for outbound;{ success }for inbound.
POST /approval-requests/:id/deny — direction-branched.
- Inbound — existing behavior preserved (sends signed
/peer/deniedto origin, upserts a localrejectedfederation_peersrow, deletes the queue row). - Outbound — fans out
kind='denied'notifications to allpeer_approval_subscribersof the queue row, then cascade-deletes the parent (no remote network call). Broadcastsfederation_peers_changedto admins so the queue UI refreshes.
Federation Peering Subscriptions (user-facing)
GET /federation/peering-subscriptions (auth) → { subscriptions: PeeringSubscriptionSummary[] }
DELETE /federation/peering-subscriptions/:id (auth) → { success }
User-facing surface for the rows in peer_approval_subscribers belonging to the calling user. GET joins the parent peer_approval_requests row to include peer origin/instance metadata.
type PeeringSubscriptionSummary = {
id: string;
requestId: string;
peerOrigin: string;
peerInstanceName: string | null;
triggerReason: 'friend_add' | 'space_join' | 'direct_message';
triggerTarget: string;
createdAt: number;
};
DELETE /peering-subscriptions/:id:
- 404 if the subscriber row doesn't exist.
- 403 if the row belongs to a different user.
- On success: deletes the subscriber row; if it was the last subscriber for the parent, cascade-deletes the parent (admin's queue row disappears too). No
peer_approval_notificationsrow is created (the user took the action; they know). - Broadcasts
peering_subscription_changedto the calling user (multi-tab refresh) andfederation_peers_changedto admins if the parent was deleted.
Federation Peering Notifications (user-facing)
GET /federation/peering-notifications (auth) ?unread=1? → { notifications: PeeringNotificationSummary[] }
POST /federation/peering-notifications/:id/read (auth) → { success }
POST /federation/peering-notifications/read-all (auth) → { success, count }
User-facing terminal-state notifications for peering events. GET orders DESC by createdAt; ?unread=1 filters to readAt IS NULL.
type PeeringNotificationSummary = {
id: string;
kind: 'approved' | 'denied' | 'expired';
peerOrigin: string;
triggerReason: 'friend_add' | 'space_join' | 'direct_message';
triggerTarget: string;
createdAt: number;
readAt: number | null;
};
POST /:id/read — sets readAt = Date.now() for the calling user's notification (404 / 403 on miss / mismatch).
POST /read-all — marks all of the calling user's unread notifications as read; returns { success, count } where count is the number of rows updated.
Utilities (routes/utils.ts) — auth required
GET /utils/metadata ?url= → { title?, description?, image?, siteName? }
GET /health (public) → { status: 'ok', timestamp }