- LICENSE -> verbatim GNU AGPL-3.0; add LICENSE-COMMERCIAL.md + SECURITY.md - CLA -> exclusive-license grant (contributors keep copyright); add README anti-rugpull covenant + relicense record - NOTICE / README / CONTRIBUTING / CLAUDE.md / package.json x5 updated; contact routed through GitHub (no email placeholders) - AGPL section 13 source offer: operator-configurable BACKSPACE_SOURCE_URL + build-injected commit; sourceCodeUrl+commit on /api/instance/info; SourceCodeLink on login/register/settings/desktop; docs + .env.example updated
30 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 ?homeInstance= → { success } [owner kick; cannot self-kick; group only; segment is homeUserId when ?homeInstance is set]
POST /dm/:id/transfer { newOwnerId? | (homeUserId+homeInstance) } → { success } [owner; resolved 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. The :targetUserId segment carries either a local user id on the owner's instance OR a federated home user id when the ?homeInstance=<origin> query string is present (server resolves via resolveOrCreateReplicatedUser — same pattern as POST /dm/:id/members). Federated form is required for federated targets, because the client's cached user view returns the user's home id, not the owner instance's local replicated id. 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. Body accepts either a local id (newOwnerId) or a federated identity (homeUserId + homeInstance). When both forms are supplied, federated args take precedence. Server resolves via resolveOrCreateReplicatedUser before checking membership — mirrors POST /dm/:id/members. 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, sourceCodeUrl, commit }
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.
sourceCodeUrl (string) and commit (string | null) implement the AGPL-3.0 § 13 network-use source offer: every network user (and federated peer) can obtain the Corresponding Source of the exact version this instance is running. sourceCodeUrl comes from config.sourceCodeUrl (env BACKSPACE_SOURCE_URL, default https://github.com/TheZwiss/backspace) — operators who modify Backspace MUST set it to their fork's source. commit comes from config.commit (env BACKSPACE_COMMIT, injected at Docker build via deploy.sh --build-arg; null in local dev). The web client surfaces sourceCodeUrl/version via the SourceCodeLink component on settings sidebars and the pre-auth login/register pages; the desktop app exposes it via the tray + app menus ("Source code (AGPL)") and the native About panel.
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 }