From ea8f786c2ba6418e23aad7debd1bbc6cb0a4b105 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:16:07 +0200 Subject: [PATCH] docs: document pending peering approval queue, new endpoints, and awaiting_approval status --- docs/systems/admin.md | 28 ++++++++++++++-- docs/systems/database.md | 14 +++++++- docs/systems/federation.md | 69 +++++++++++++++++++++++++++----------- docs/systems/websocket.md | 5 ++- 4 files changed, 93 insertions(+), 23 deletions(-) diff --git a/docs/systems/admin.md b/docs/systems/admin.md index c5967a0f..5d09e609 100644 --- a/docs/systems/admin.md +++ b/docs/systems/admin.md @@ -358,6 +358,29 @@ The temporary password is shown exactly once in the admin UI -- the UsersPanel d 3. Force-disconnect all WebSocket sessions 4. Return `{ success: true }` +### Peering Approval Requests + +All admin-only. Only present when `autoAcceptPeering` is `false` and incoming peering requests are queued. + +``` +GET /api/federation/approval-requests → PeerApprovalRequest[] +POST /api/federation/approval-requests/:id/approve → { success: boolean } +POST /api/federation/approval-requests/:id/deny → { success: boolean } +``` + +`PeerApprovalRequest` shape: +```typescript +{ + id: string; // Snowflake + origin: string; // Requesting instance URL + instanceName: string | null; + requestedAt: number; // Epoch ms + expiresAt: number; // Epoch ms (requestedAt + 30 days) +} +``` + +See [federation.md](federation.md) — Peer Approval Queue section for the full approval/denial/expiry flow. + --- ## Frontend Architecture @@ -399,11 +422,12 @@ All panels live under `packages/web/src/components/modals/instanceSettingsPanels #### GeneralPanel -Manages: instance name, registration toggle, discovery toggle, GIF API key, federation relay toggle/TTL, peered instances list. +Manages: instance name, registration toggle, discovery toggle, GIF API key, federation relay toggle/TTL, pending approval requests, peered instances list. - Instance name input: max 32 chars, enforced client-side via `slice(0, 32)` - GIF key: password input, separate dirty tracking (`gifKeyDirty`). Only sent on save if modified. "Clear key" button sets empty string. -- Federation peers: fetched via `api.federation.peers()`, displayed as a list with status badges (active/pending/unreachable), last-seen/synced times, revoke button +- **Pending Approvals section:** Visible only when `pendingApprovalCount > 0` (from ready payload). Positioned above the peer list. Each row shows the requesting instance name and origin with Approve and Deny buttons. Approve calls `api.federation.approveApprovalRequest(id)` and Deny calls `api.federation.denyApprovalRequest(id)`; both remove the row from the local list on success. +- Federation peers: fetched via `api.federation.peers()`, displayed as a list with status badges (active/pending/unreachable/awaiting_approval), last-seen/synced times, revoke button - Peers with status `'revoked'` are filtered out of the visible list - Revoke calls `api.federation.revokePeer(peerId)` and removes from local list diff --git a/docs/systems/database.md b/docs/systems/database.md index 3e8f1d9a..dbbf70fa 100644 --- a/docs/systems/database.md +++ b/docs/systems/database.md @@ -357,7 +357,7 @@ Migration flags (internal): `voice_bit_migrated`, `profile_attachments_cleaned`, | origin | text NOT NULL UNIQUE | | `https://domain.tld` | | instanceName | text | | | | hmacSecret | text NOT NULL | | 256-bit hex | -| status | text NOT NULL | `'active'` | active/pending/unreachable/revoked/rejected | +| status | text NOT NULL | `'active'` | active/pending/awaiting_approval/unreachable/revoked/rejected | | lastSeenAt | integer | | | | lastFailureAt | integer | | | | consecutiveFailures | integer | 0 | >=10 → unreachable | @@ -365,6 +365,18 @@ Migration flags (internal): `voice_bit_migrated`, `profile_attachments_cleaned`, | remoteMaxUploadSize | integer | | Bytes, from peer | | createdAt | integer NOT NULL | | | +### peer_approval_requests +Holds incoming peering requests queued for admin review when `autoAcceptPeering` is `false`. One row per requesting origin (UNIQUE constraint). Rows expire after 30 days via janitor cleanup. + +| Column | Type | Default | Notes | +|--------|------|---------|-------| +| id | text PK | | Snowflake | +| origin | text NOT NULL UNIQUE | | Requesting instance's origin URL | +| instanceName | text | | Instance name sent by requester | +| hmacSecret | text NOT NULL | | Requester's HMAC secret; used to sign denial notification | +| requestedAt | integer NOT NULL | | Epoch ms | +| expiresAt | integer NOT NULL | | Epoch ms; requestedAt + 30 days | + ### federation_outbox UNIQUE: (peerId, entityId) | Column | Type | Default | Notes | diff --git a/docs/systems/federation.md b/docs/systems/federation.md index 7155c7bb..a663b850 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -72,30 +72,33 @@ Both instances store the **same** HMAC secret. The initiating instance generates ### Peer Status Lifecycle ``` - initiate + ensurePeered (none) ──────────► pending ──────────► active - ▲ │ - │ 10+ consecutive │ delivery failures - │ failures ▼ - │ unreachable - │ health check OK │ - │ ▼ - │ active - │ admin revoke │ - │ ▼ - │ revoked ──► (delete) ──► re-initiate + ▲ │ │ + │ │ remote 202 │ delivery failures + │ ▼ ▼ + │ awaiting_approval unreachable + │ │ │ + │ ┌───────────┼──────────┐ │ health check OK + │ │ │ │ ▼ + │ accept denied expired active + │ (fresh) (admin) (janitor) │ + │ │ │ │ │ admin revoke + │ ▼ ▼ ▼ ▼ + │ active rejected rejected revoked │ │ auto-peer rejected (403 PEERING_REQUIRES_APPROVAL) └──────────────────────────── rejected ``` -| Status | Outbox delivery | Health check | Relay accepts | Re-initiation | -|--------|----------------|--------------|---------------|---------------| -| `active` | Yes | No | Yes | No (returns existing) | -| `pending` | No | No | No | No (returns 409) | -| `unreachable` | No (entries wait) | Yes (1h interval) | Yes (resets to active) | No | -| `revoked` | No (entries purged) | No | No (returns 403) | Yes (old record deleted) | -| `rejected` | No | No | No | Yes (admin deletes record, then re-initiates) | +| Status | Outbox delivery | Health check | Relay accepts | Re-initiation | Admin clear | +|--------|----------------|--------------|---------------|---------------|-------------| +| `active` | Yes | No | Yes | No (returns existing) | N/A | +| `pending` | No | No | No | No (returns 409) | N/A | +| `awaiting_approval` | No | No | No | Returns pending; no re-handshake | Yes (admin deletes) | +| `unreachable` | No (entries wait) | Yes (1h interval) | Yes (resets to active) | No | N/A | +| `revoked` | No (entries purged) | No | No (returns 403) | Yes (old record deleted) | N/A | +| `rejected` | No | No | No | Yes (admin deletes record, then re-initiates) | N/A | ### PEER_UNREACHABLE_THRESHOLD @@ -113,6 +116,30 @@ When the server needs to relay events to an instance it has not yet peered with, **`autoAcceptPeering` instance setting** — Controls whether `POST /api/federation/peer/accept` accepts unsolicited peering requests. Default: `true`. When `false`, the endpoint returns `403 PEERING_REQUIRES_APPROVAL` for requests where no local `pending` record exists (i.e., a request the local admin did not initiate). The determination is made by checking the local peer table — not a client-provided flag. +### Peer Approval Queue + +When `autoAcceptPeering` is `false` and an instance calls `POST /api/federation/peer/accept` without a matching local `pending` record, the endpoint returns `202 Accepted` and creates a row in `peer_approval_requests` instead of immediately peering. The requesting instance receives `202` (not an error), so it enters `awaiting_approval` status rather than `rejected`. + +**`peer_approval_requests` table** — Holds incoming peering requests pending admin review: +- `id` — Snowflake PK +- `origin` — Requesting instance's origin URL (UNIQUE; only one pending request per origin) +- `instance_name` — Instance name sent by requester +- `hmac_secret` — Requester's HMAC secret; used to sign the denial notification +- `requested_at` / `expires_at` — Epoch ms; expiry is `requested_at + 30 days` + +**Approval flow** — Admin approves via `POST /api/federation/approval-requests/:id/approve`: +1. A fresh `federationPeer` record is created (or existing `rejected`/`awaiting_approval` record is upserted) with status `pending` +2. A standard `peer/accept` handshake is sent to the requesting origin +3. On success the local peer becomes `active`; the `peer_approval_requests` row is deleted + +**Denial flow** — Admin denies via `POST /api/federation/approval-requests/:id/deny`: +1. Server sends `POST {origin}/api/federation/peer/denied` signed with the requester's `hmac_secret` (from the approval request row) +2. Receiving instance transitions its local peer record from `awaiting_approval` → `rejected` +3. A local `federationPeer` record is upserted with status `rejected` to block future unsolicited requests from the same origin +4. The `peer_approval_requests` row is deleted + +**Expiry** — The janitor (`federationJanitor.ts`) runs on its scheduled interval and deletes rows where `expires_at < now`. Expired requests do NOT create a `rejected` peer — the requesting instance can re-submit. Admin denial, by contrast, does create a `rejected` peer record, blocking re-requests until an admin clears it. + ### Admin Endpoints | Endpoint | Method | Auth | Purpose | @@ -125,14 +152,18 @@ When the server needs to relay events to an instance it has not yet peered with, | `/api/federation/peers/:id` | DELETE | JWT + admin | Revoke peer, purge outbox | | `/api/federation/peers/:id/permanent` | DELETE | JWT + admin | Hard-delete revoked peer record | | `/api/federation/peers/:id/rotate` | POST | JWT + admin | Trigger immediate secret rotation | +| `/api/federation/approval-requests` | GET | JWT + admin | List pending peering approval requests | +| `/api/federation/approval-requests/:id/approve` | POST | JWT + admin | Approve request, initiate handshake | +| `/api/federation/approval-requests/:id/deny` | POST | JWT + admin | Deny request, notify requester | -**`POST /api/federation/peer/ensure`** — Wraps `ensurePeered()`. Accepts `{ remoteOrigin: string }` in body. Returns `{ peeringStatus, peerId?, error? }` where `peeringStatus` is one of `active`, `pending`, `rejected`, `unreachable`, or `revoked`. +**`POST /api/federation/peer/ensure`** — Wraps `ensurePeered()`. Accepts `{ remoteOrigin: string }` in body. Returns `{ peeringStatus, peerId?, error? }` where `peeringStatus` is one of `active`, `pending`, `awaiting_approval`, `rejected`, `unreachable`, or `revoked`. ### S2S Endpoints | Endpoint | Method | Auth | Purpose | |----------|--------|------|---------| | `/api/federation/peer/rotate` | POST | HMAC | Accept secret rotation from peer | +| `/api/federation/peer/denied` | POST | HMAC | Receive denial notification for awaiting_approval peer | | `/api/federation/identity` | DELETE | HMAC | Delete federated user identity (soft/full mode) | ### S2S Identity Deletion (`DELETE /api/federation/identity`) diff --git a/docs/systems/websocket.md b/docs/systems/websocket.md index 1d603377..5a51a7bf 100644 --- a/docs/systems/websocket.md +++ b/docs/systems/websocket.md @@ -214,7 +214,10 @@ reason: `'displaced'` (new tab) | `'session_closed'` spaceVoiceStates?: Record, readStates?: ReadState[], activeCalls?: ActiveCallInfo[], // includes federatedCallHost?, livekitUrl?, livekitToken? for federated calls - userActivities?: Record + userActivities?: Record, + rejectedPeerOrigins: string[], // origins with status 'rejected'; used for DM unreachable indicators + awaitingApprovalPeerOrigins: string[], // origins with status 'awaiting_approval' + pendingApprovalCount: number // count of peer_approval_requests rows; only non-zero for admins } ```