docs(systems): outbound peering gate documentation across federation, db, api, ws, admin, client-federation
- federation.md: Outbound Peering Gate subsection (gate, intent contract, gate-but-don't-queue split, lifecycle invariant on onPeerActivated) - database.md: peer_approval_requests direction + nullable hmac_secret + UNIQUE relaxation + CHECK; new peer_approval_subscribers + peer_approval_ notifications tables - api.md: /approval-requests direction-branched approve/deny + extended GET response; new peering-subscriptions and peering-notifications endpoints - websocket.md: peering_subscription_changed, peering_notification_received events; federation_approval_request_received fires for outbound too - admin.md: outbound row rendering with subscriber list - client-federation.md: 'admin_required' status, peer_pending_local_admin error code, new Connections surfaces, federationStore slice Spec §11 closes the Approve-button investigation finding.
This commit is contained in:
+21
-12
@@ -361,26 +361,31 @@ The temporary password is shown exactly once in the admin UI -- the UsersPanel d
|
||||
|
||||
### Peering Approval Requests
|
||||
|
||||
All admin-only. Only present when `autoAcceptPeering` is `false` and incoming peering requests are queued.
|
||||
All admin-only. Present when `autoAcceptPeering` is `false`. The queue holds **both directions**:
|
||||
- **Inbound** rows — remote instances asking to peer with us.
|
||||
- **Outbound** rows — local users who initiated peering (friend-add, etc.) that the local [Outbound Peering Gate](federation.md#outbound-peering-gate) intercepted because no `federation_peers` row exists yet for the target.
|
||||
|
||||
```
|
||||
GET /api/federation/approval-requests → PeerApprovalRequest[]
|
||||
POST /api/federation/approval-requests/:id/approve → { success: boolean }
|
||||
POST /api/federation/approval-requests/:id/deny → { success: boolean }
|
||||
GET /api/federation/approval-requests → { requests: ApprovalRequestSummary[] }
|
||||
POST /api/federation/approval-requests/:id/approve → { success, peerStatus?, peer? }
|
||||
POST /api/federation/approval-requests/:id/deny → { success }
|
||||
```
|
||||
|
||||
`PeerApprovalRequest` shape:
|
||||
`ApprovalRequestSummary` (see [api.md → Federation Peering Approval Queue](api.md#federation-peering-approval-queue) for the complete TypeScript shape):
|
||||
```typescript
|
||||
{
|
||||
id: string; // Snowflake
|
||||
origin: string; // Requesting instance URL
|
||||
id: string;
|
||||
direction: 'inbound' | 'outbound';
|
||||
origin: string;
|
||||
instanceName: string | null;
|
||||
requestedAt: number; // Epoch ms
|
||||
expiresAt: number; // Epoch ms (requestedAt + 30 days)
|
||||
requestedAt: number;
|
||||
expiresAt: number;
|
||||
// Outbound rows ONLY — inbound rows omit this field entirely (absent, not null and not []).
|
||||
subscribers?: Array<{ id, userId, username, triggerReason, triggerTarget, createdAt }>;
|
||||
}
|
||||
```
|
||||
|
||||
See [federation.md](federation.md) — Peer Approval Queue section for the full approval/denial/expiry flow.
|
||||
See [federation.md](federation.md) — Peer Approval Queue and Outbound Peering Gate sections for the full approval/denial/expiry flow (including the direction-branched approve/deny semantics, `onPeerActivated` cleanup invariant, and the inbound-expiry `/peer/denied` notification preserved unchanged).
|
||||
|
||||
---
|
||||
|
||||
@@ -431,9 +436,13 @@ Manages: instance name, registration toggle, discovery toggle, GIF API key, fede
|
||||
|
||||
#### FederationPanel
|
||||
|
||||
Manages: federation peers list, pending approval requests, manual peering initiation, secret rotation, peer reset.
|
||||
Manages: federation peers list, pending approval requests (inbound + outbound), manual peering initiation, secret rotation, peer reset.
|
||||
|
||||
- **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.
|
||||
- **Pending Approvals section:** Visible only when `pendingApprovalCount > 0` (from ready payload — the count sums inbound + outbound rows). Positioned above the peer list. Both directions render as rows in the same unified queue, branched on `direction`:
|
||||
- **Inbound rows** — "{instanceName} ({origin}) — wants to peer with us." Approve / Deny buttons.
|
||||
- **Outbound rows** — "{instanceName} ({origin}) — N user(s) want us to peer with them. Triggered by: friend-add (etc.)." Inline expansion reveals the subscriber list (`username — friend_add → alice@orbit`, ...). Approve / Deny buttons same as inbound; backend branches on direction.
|
||||
- Approve calls `api.federation.approveApprovalRequest(id)` and Deny calls `api.federation.denyApprovalRequest(id)`. Both remove the row from the local list on success and refresh on `federation_approval_request_received` (which now also fires for outbound queue creation) and `federation_peers_changed`.
|
||||
- **ConfirmDialog copy variants:** the dialog branches on direction. Outbound approve confirms "send `/peer/accept` to {origin} on behalf of N user(s)"; outbound deny confirms "fan out denied notifications to N user(s) and discard the queued request" (no remote network call).
|
||||
- Federation peers: fetched via `api.federation.peers()`, displayed as a list with status badges (active/pending/unreachable/awaiting_approval/rejected/needs_attention), last-seen/synced times, and per-peer actions.
|
||||
- Peers with status `'revoked'` are filtered out of the visible list.
|
||||
- Revoke calls `api.federation.revokePeer(peerId)` and removes from local list.
|
||||
|
||||
@@ -142,6 +142,7 @@ GET /social/search ?q= → { users[] }
|
||||
| 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](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 |
|
||||
@@ -229,6 +230,104 @@ POST /federation/users/lookup (HMAC-signed S2S, rate-limited 60/min/peer)
|
||||
|
||||
**`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](federation.md#peer-approval-queue) and [federation.md → Outbound Peering Gate](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:**
|
||||
|
||||
```typescript
|
||||
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_peers` row with status `pending`, sends `/peer/accept` to origin forwarding the stored `approvalToken`, deletes the queue row regardless of whether the result is `active` (200) or `awaiting_approval` (202)).
|
||||
- **Outbound** — generates a fresh HMAC, sends `/peer/accept` to the origin (no token; we are the initiator).
|
||||
- On 200 → peer becomes `active`. `onPeerActivated` runs: fans out `kind='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 returned `approvalToken`, and the queue row + subscribers are LEFT INTACT for the eventual remote-admin approval. `onPeerActivated` is 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`).
|
||||
- **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/denied` to origin, upserts a local `rejected` `federation_peers` row, deletes the queue row).
|
||||
- **Outbound** — fans out `kind='denied'` notifications to all `peer_approval_subscribers` of the queue row, then cascade-deletes the parent (no remote network call). Broadcasts `federation_peers_changed` to 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.
|
||||
|
||||
```typescript
|
||||
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_notifications` row is created (the user took the action; they know).
|
||||
- Broadcasts `peering_subscription_changed` to the calling user (multi-tab refresh) and `federation_peers_changed` to 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`.
|
||||
|
||||
```typescript
|
||||
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? }
|
||||
|
||||
@@ -340,7 +340,58 @@ Client-driven LWW whole-registry push (same pattern as `profileSync.ts`):
|
||||
|
||||
---
|
||||
|
||||
## 8. Relationship to S2S Federation
|
||||
## 8. Outbound Peering Gate (client surfaces)
|
||||
|
||||
When the local instance has `autoAcceptPeering=0`, every outbound new-peer attempt funnels through the centralized [Outbound Peering Gate](federation.md#outbound-peering-gate) on the server. The client surfaces three things: a new error code on the friend-add path, a new peering-status value on `/peer/ensure`, and two new Connections-settings panels (pending and outcomes).
|
||||
|
||||
### Peering-status taxonomy (`/peer/ensure` response)
|
||||
|
||||
`peeringStatus` returned from `POST /api/federation/peer/ensure` now includes `'admin_required'` alongside the existing `'active' | 'pending' | 'awaiting_approval' | 'rejected' | 'unreachable' | 'revoked'`. `'admin_required'` means: gate fired locally, your own admin must approve before any traffic reaches the wire. The user's request becomes admin-approvable rather than auto-firing.
|
||||
|
||||
### Friend-add error mapping (`peer_pending_local_admin`)
|
||||
|
||||
`POST /api/social/requests` returns 409 `peer_pending_local_admin` when the gate fires for a never-peered remote target (the user's request is queued + subscriber-tracked on the server; admin must approve).
|
||||
|
||||
`packages/web/src/utils/friendErrors.ts` maps `peer_pending_local_admin` to:
|
||||
|
||||
> "Your admin needs to approve federation with this instance. You'll see your request in Connections settings."
|
||||
|
||||
The catch handler reads `err.message` (per the API client error contract documented in §1) and passes it to `mapServerErrorToMessage`. Distinct from `peer_pending_approval` ("the *remote* admin must approve") — this one is local-admin gating.
|
||||
|
||||
### Connections settings — Pending peering approvals
|
||||
|
||||
A new section in the Connections settings UI (alongside the federation registry) lists the calling user's rows from `peer_approval_subscribers`, joined to parent `peer_approval_requests`. Each row renders:
|
||||
|
||||
- "Awaiting your admin's approval to federate with `{peerOrigin}` so you can `friend_add → alice@orbit`."
|
||||
- A Cancel button. Cancel calls `DELETE /api/federation/peering-subscriptions/:id`. If the cancelled row was the last subscriber for the parent, the parent cascades and disappears from the admin's queue too.
|
||||
|
||||
Live updates: a `peering_subscription_changed` WebSocket event refetches the list.
|
||||
|
||||
### Connections settings — Recent peering outcomes
|
||||
|
||||
A second new section above the pending list shows unread `peer_approval_notifications` rows ordered by `createdAt DESC`. Each row's copy branches on `kind`:
|
||||
|
||||
- **`approved`** — "Your peering request to `{peerOrigin}` was approved — retry your friend-add to `{triggerTarget}`?" `[Retry]` `[Dismiss]`. Retry deep-links to the friend-add UI prefilled with the original target. Today only the `friend_add` reason produces a retry deep-link; future trigger reasons add their own deep-link flows. Dismiss POSTs to `/peering-notifications/:id/read`.
|
||||
- **`denied`** — "Your peering request to `{peerOrigin}` was denied by your admin." `[Dismiss]`.
|
||||
- **`expired`** — "Your peering request to `{peerOrigin}` expired without admin action." `[Dismiss]`.
|
||||
|
||||
A "Mark all as read" action POSTs to `/peering-notifications/read-all`. Read rows hide from view (soft-delete preserves audit; the storage janitor cleans up read rows older than 30 days).
|
||||
|
||||
Live updates: a `peering_notification_received` WebSocket event refetches the list and may surface a transient toast for the matching `kind` (online users only).
|
||||
|
||||
### Federation store slice
|
||||
|
||||
A new `federationStore.ts` slice (separate from `instanceStore`) holds:
|
||||
|
||||
- `peeringSubscriptions: PeeringSubscriptionSummary[]`
|
||||
- `peeringNotifications: PeeringNotificationSummary[]`
|
||||
- `pendingFriendAddPrefill?: { username: string }` — side-channel populated by the Retry button on `kind='approved'` notifications, consumed by the friend-add modal on next open.
|
||||
|
||||
This slice is intentionally separate from `instanceStore` because the data is per-user (not per-instance) and lives on the home server only. WebSocket handlers route `peering_subscription_changed` and `peering_notification_received` events into this slice's refetch actions.
|
||||
|
||||
---
|
||||
|
||||
## 9. Relationship to S2S Federation
|
||||
|
||||
Client-side and S2S federation serve different purposes:
|
||||
|
||||
|
||||
@@ -366,17 +366,62 @@ PK: (spaceId, userId, restrictionType)
|
||||
| approvalToken | text | | Single-use 64-hex-char token stored when this row is in `awaiting_approval` (received from remote's 202 response). Verified against the inbound `/peer/accept` `approvalToken` field before promoting to `active`. Cleared (`NULL`) on promotion. See [federation.md → Approval Token Verification](federation.md#approval-token-verification). |
|
||||
|
||||
### 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.
|
||||
Queue of peering requests pending admin review when `autoAcceptPeering` is `false`. Holds **both directions**: inbound rows (remote asked to peer with us) and outbound rows (a local user-initiated `ensurePeered` call gated on this side; see [federation.md → Outbound Peering Gate](federation.md#outbound-peering-gate)). UNIQUE on `(origin, direction)` so the same origin may have at most one row per direction simultaneously. 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 |
|
||||
| origin | text NOT NULL | | Requesting / target instance's origin URL. UNIQUE per `direction` (composite UNIQUE `(origin, direction)`). |
|
||||
| direction | text NOT NULL | `'inbound'` | `'inbound'` (remote → us) or `'outbound'` (us → remote, gate-created on user_action). Migration backfills existing rows to `'inbound'`. |
|
||||
| instanceName | text | | Instance name (sent by requester for inbound; null for outbound until populated by future enrichment). |
|
||||
| hmacSecret | text | | Requester's HMAC secret for inbound (used to sign the `/peer/denied` notification). **Nullable** — outbound rows have `hmac_secret = NULL` and the `/approve` handler generates fresh HMAC at the moment it sends `/peer/accept`. CHECK enforced (see below). |
|
||||
| requestedAt | integer NOT NULL | | Epoch ms |
|
||||
| expiresAt | integer NOT NULL | | Epoch ms; requestedAt + 30 days |
|
||||
| approvalToken | text | | Single-use 64-hex-char token issued in the 202 response when this row is created. Forwarded by `/approve` in its outbound `/peer/accept` so the remote initiator can verify mutual admin approval. Deleted along with this row when `/approve` runs. See [federation.md → Approval Token Verification](federation.md#approval-token-verification). |
|
||||
| approvalToken | text | | Single-use 64-hex-char token issued by the receiver in the 202 response when an inbound row is created. Forwarded by `/approve` in its outbound `/peer/accept` so the remote initiator can verify mutual admin approval. Deleted along with this row when `/approve` runs. Inbound-only meaning preserved (outbound rows always have `approval_token = NULL`). See [federation.md → Approval Token Verification](federation.md#approval-token-verification). |
|
||||
|
||||
**CHECK constraint** (direction-specific shape):
|
||||
|
||||
```sql
|
||||
CHECK (
|
||||
(direction = 'inbound' AND hmac_secret IS NOT NULL)
|
||||
OR (direction = 'outbound')
|
||||
)
|
||||
```
|
||||
|
||||
> **drizzle-kit limitation:** drizzle-kit does NOT represent SQLite CHECK constraints in its snapshot/diff. The constraint is created by the original baseline migration (or, for the post-baseline ALTER, hand-written SQL) and is preserved in `schema.ts` as a comment so a future table recreate (drizzle generates a recreate when UNIQUE/PK changes again) re-adds the CHECK by hand. If you regenerate a migration that recreates this table, audit the generated SQL and re-add the CHECK clause manually before applying.
|
||||
|
||||
### peer_approval_subscribers
|
||||
Per-user "I want this peering relationship" subscriber rows attached to outbound `peer_approval_requests`. Logically outbound-only (inbound rows have no subscribers). Existence = waiting; deletion = resolved (resolution recorded in `peer_approval_notifications` at deletion time, except for the canceller path). One subscriber may have multiple rows on the same parent if they triggered the gate from different actions/targets.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | text PK | Snowflake |
|
||||
| requestId | text NOT NULL | FK → peer_approval_requests.id CASCADE — parent deletion (admin approve→active fanout, admin deny, last-subscriber cancel, expiry) automatically clears subscriber rows. |
|
||||
| userId | text NOT NULL | FK → users.id CASCADE |
|
||||
| triggerReason | text NOT NULL | `'friend_add'` \| `'space_join'` \| `'direct_message'` (`PeeringTriggerReason` enum in `packages/shared/src/types.ts`). |
|
||||
| triggerTarget | text NOT NULL | Action target — for `friend_add` this is `username@instance`; for `space_join` an invite code or space ID; for `direct_message` a recipient handle. Never stores message bodies, attachments, or user content. |
|
||||
| createdAt | integer NOT NULL | Epoch ms |
|
||||
|
||||
**UNIQUE:** `(request_id, user_id, trigger_reason, trigger_target)` — same user retriggering the gate with the same reason+target updates rather than duplicates.
|
||||
**Index:** `idx_peer_approval_subscribers_user_id` on `(user_id)` — supports the user-facing pending list query.
|
||||
|
||||
### peer_approval_notifications
|
||||
Terminal-state notifications for peering events (approved / denied / expired). Scoped to peering — NOT a generalized in-app notification system. When a generalized system is built later, this table either migrates into it or stays as a peering-specific artifact (decision deferred to that spec).
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | text PK | Snowflake |
|
||||
| userId | text NOT NULL | FK → users.id CASCADE |
|
||||
| kind | text NOT NULL | `'approved'` \| `'denied'` \| `'expired'`. |
|
||||
| peerOrigin | text NOT NULL | Origin URL the notification refers to. |
|
||||
| triggerReason | text NOT NULL | Mirrors the originating subscriber row's `trigger_reason`. |
|
||||
| triggerTarget | text NOT NULL | Mirrors the originating subscriber row's `trigger_target`. |
|
||||
| createdAt | integer NOT NULL | Epoch ms |
|
||||
| readAt | integer | Nullable. NULL = unread; epoch ms once dismissed/marked read. |
|
||||
|
||||
**Index:** `idx_peer_approval_notifications_user_id` on `(user_id)` — supports the user-facing list and unread-filter queries.
|
||||
|
||||
Inserted by `onPeerActivated` (`'approved'`), the outbound `/deny` handler (`'denied'`), and the storage janitor outbound expiry pass (`'expired'`). Read rows older than 30 days are auto-cleaned by the janitor; unread rows are never auto-cleaned.
|
||||
|
||||
### federation_outbox
|
||||
UNIQUE: (peerId, entityId)
|
||||
|
||||
@@ -176,7 +176,7 @@ When `autoAcceptPeering` is `false` and an instance calls `POST /api/federation/
|
||||
|
||||
**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.
|
||||
|
||||
**Pre-handshake guard (`ensurePeered`)** — Before any outbound handshake, `ensurePeered(origin)` in `federationPeering.ts` refuses with `{ status: 'rejected', error: 'Local admin must resolve…' }` if a `peer_approval_requests` row exists for that origin. This blocks the auto-reconnect trigger: without the guard, any code path calling `ensurePeered` (e.g., the silent reconnect in `stores/instanceStore.ts`) could initiate a fresh outbound handshake to a peer that has a pending inbound approval request. The legitimate approve flow (`POST /api/federation/approval-requests/:id/approve`) does NOT call `ensurePeered` — it deletes the approval-request row and does its own direct `fetch` to `/peer/accept` — so the guard does not block legitimate approvals.
|
||||
**Pre-handshake guard (`ensurePeered`)** — Before any outbound handshake, `ensurePeered(origin)` in `federationPeering.ts` refuses with `{ status: 'rejected', error: 'Local admin must resolve…' }` if an **inbound** `peer_approval_requests` row exists for that origin. The guard query is narrowed to `direction='inbound'` (commit `0d3d087`) so the gate's own outbound queue rows do not falsely block their own approval path. This blocks the auto-reconnect trigger: without the guard, any code path calling `ensurePeered` (e.g., the silent reconnect in `stores/instanceStore.ts`) could initiate a fresh outbound handshake to a peer that has a pending inbound approval request. The legitimate approve flow (`POST /api/federation/approval-requests/:id/approve`) does NOT call `ensurePeered` — it deletes the approval-request row and does its own direct `fetch` to `/peer/accept` — so the guard does not block legitimate approvals.
|
||||
|
||||
### Approval Token Verification
|
||||
|
||||
@@ -196,7 +196,73 @@ The pre-handshake guard above closes the most reliable trigger but cannot preven
|
||||
|
||||
**Backward compatibility.** Both schema columns are nullable. Older peers that don't include `approvalToken` in the request body or 202 response result in `null` storage; the receiver's verification then falls through the `autoAcceptPeering` gate. Existing `active` peers and existing `awaiting_approval` rows in production at upgrade time are unaffected — the verification only runs on the receiver's `awaiting_approval` branch. Stalled legacy `awaiting_approval` rows (no stored token) cannot complete via inbound `/peer/accept` from a legacy initiator unless the receiver is `autoAccept=1`; admins should re-initiate them through the standard flow if needed.
|
||||
|
||||
**What this does NOT defend against** — a remote operator running custom code with full DB access can read their stored token and forge `/peer/accept`. That is the inherent trust radius of federation peering. The threat model is bug-prone code paths (auto-reconnect, voice-call peering races, future `ensurePeered` callers) on otherwise-honest peers, not adversarial operators. Sender-side outbound gating for `autoAcceptPeering=0` is a separate concern tracked as a follow-up.
|
||||
**What this does NOT defend against** — a remote operator running custom code with full DB access can read their stored token and forge `/peer/accept`. That is the inherent trust radius of federation peering. The threat model is bug-prone code paths (auto-reconnect, voice-call peering races, future `ensurePeered` callers) on otherwise-honest peers, not adversarial operators. Sender-side outbound gating for `autoAcceptPeering=0` was tracked as the "Direction C" follow-up and is now closed by the [Outbound Peering Gate](#outbound-peering-gate) below (spec `docs/superpowers/specs/2026-04-26-outbound-peering-gate-design.md`, commits `ac2565f..c795d72`).
|
||||
|
||||
### Outbound Peering Gate
|
||||
|
||||
The receiver-side trust class is closed by the approval-token mechanism above. The sender-side trust class — *bug-prone or incidental code paths on the initiator that drag the local instance into peering relationships without local admin consent* — is closed by a centralized gate inside `ensurePeered`. After this change, `autoAcceptPeering=0` is symmetric: BOTH inbound and outbound new-peer establishment require local admin approval. Existing active peers keep working unchanged.
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-04-26-outbound-peering-gate-design.md`.
|
||||
|
||||
**Gate location.** `ensurePeered` (`utils/federationPeering.ts`) is the single chokepoint every outbound new-peer attempt funnels through (friend-add, `/peer/ensure`, `sendCallRelay`'s no-active-peer branch, future callers). The gate runs ONLY when **no `federation_peers` row exists for the origin**. If any peer row exists in any status (`active`, `pending`, `awaiting_approval`, `rejected`, `revoked`, `unreachable`, `needs_attention`), the gate is a no-op — the existing branches in `ensurePeered` handle the row as today. This matches the threat model: the worry is automated paths creating *new* peerings without admin consent.
|
||||
|
||||
**Required caller intent.** Every call site MUST pass an explicit `EnsurePeeredCallerIntent` (declared in `packages/shared/src/types.ts`). The argument is required at the type level so a future caller cannot silently fall through to system behavior:
|
||||
|
||||
```ts
|
||||
export type PeeringTriggerReason = 'friend_add' | 'space_join' | 'direct_message';
|
||||
|
||||
export type EnsurePeeredCallerIntent =
|
||||
| { kind: 'user_action'; userId: string; reason: PeeringTriggerReason; target: string }
|
||||
| { kind: 'system' };
|
||||
|
||||
export type EnsurePeeredResult =
|
||||
| { status: 'active'; peerId: string }
|
||||
| { status: 'rejected'; error: string }
|
||||
| { status: 'failed'; error: string }
|
||||
| { status: 'pending'; error: string }
|
||||
| { status: 'admin_required'; error: string }; // ← new variant
|
||||
```
|
||||
|
||||
(Type was originally drafted as `TriggerReason` and renamed to `PeeringTriggerReason` in commit `f6487a2` to disambiguate from unrelated outbox/voice trigger enums.)
|
||||
|
||||
**Gate-but-don't-queue split.** When the gate fires (no peer row + `autoAcceptPeering=0`), behavior diverges on intent:
|
||||
|
||||
- **`intent.kind === 'user_action'`** — upsert an outbound `peer_approval_requests` row keyed on `(origin, direction='outbound')`, upsert a `peer_approval_subscribers` row keyed on `(request_id, user_id, trigger_reason, trigger_target)`, broadcast `federation_approval_request_received` to admins, broadcast `peering_subscription_changed` to the requesting user, return `{ status: 'admin_required', error: 'Awaiting your admin\'s approval to initiate peering' }`. The user's request is admin-approvable but no traffic reaches the wire yet.
|
||||
- **`intent.kind === 'system'`** — no DB writes, no admin broadcast, no admin-visible queue clutter; return `{ status: 'admin_required', error: 'Outbound peering requires admin approval on this instance' }`. A stale outbox event or dead voice-call has no admin remedy worth surfacing.
|
||||
|
||||
**`'admin_required'` result semantics.** Each user-action caller maps the new variant to its own surface (e.g. friend-add returns `409 peer_pending_local_admin`; `sendCallRelay` returns `peer_admin_required` and threads through the existing exhaustive `CallRelayFailureReason` switch). See `social.md` §6 outbound flow for the friend-add error mapping.
|
||||
|
||||
**Lifecycle (centralized cleanup on `onPeerActivated`).** The most important correctness invariant: outbound subscriber cleanup hangs off **status transition to `active` (`onPeerActivated`)**, NOT off the local admin's approve-action handler. This holds across every activation path:
|
||||
|
||||
- queue approval (`/api/federation/approval-requests/:id/approve` 200 branch)
|
||||
- admin-direct (`/peer/initiate`)
|
||||
- autoAccept=1 remote (`/peer/accept` 200 from a remote that auto-accepts)
|
||||
- mutual-token approval (the receiver's `/peer/accept` verifying `approvalToken` and promoting `awaiting_approval → active`)
|
||||
|
||||
When `federation_peers.status` transitions to `active`, `onPeerActivated` (`utils/federationPeerActivation.ts`) calls `fanoutOutboundSubscribers(origin)`: it queries the outbound `peer_approval_requests` row for the activated origin, inserts a `kind='approved'` row in `peer_approval_notifications` for each subscriber, broadcasts `peering_notification_received` per subscriber, then deletes the parent `peer_approval_requests` row (cascade clears subscribers). No per-action code knows about subscribers; the queue-approval handler does NOT duplicate this cleanup — it just performs the handshake and lets the resulting status transition trigger fanout.
|
||||
|
||||
The reason cleanup must NOT live in the approve-action handler: when the remote also has `autoAcceptPeering=0`, local admin approval transitions our peer row to `awaiting_approval`, not `active`. Subscribers must remain queued until full activation completes (which may be days later when the remote admin approves). Wiring cleanup to the approve-action handler instead would clear subscribers prematurely; users would retry against an `awaiting_approval` peer and hit `peer_pending_approval` 409, confused.
|
||||
|
||||
The other lifecycle exits write notifications and cascade-delete the parent at the trigger site:
|
||||
|
||||
- **Admin denies an outbound request** (`POST /api/federation/approval-requests/:id/deny`, `direction='outbound'` branch) — fans out `kind='denied'` notifications to subscribers, then deletes the parent (cascade clears subscribers). No remote network call — the remote never knew we were considering this peer. `federation_peers_changed` is broadcast to admins so the queue UI refreshes.
|
||||
- **Last-subscriber cancel** (`DELETE /api/federation/peering-subscriptions/:id`) — deletes the subscriber row; if it was the last subscriber for the parent, cascade-deletes the parent. No notification created (the user took the action; they know).
|
||||
- **Parent-row expiry** (`storageJanitor.ts` outbound branch) — fans out `kind='expired'` notifications to subscribers before deleting the parent. **Inbound expiry behavior is preserved unchanged** from pre-branch: the janitor still sends a signed `/peer/denied` to the inbound origin and only deletes the row on success (the Task 9 first-pass mistakenly removed this; commit `c4e7438` restored it).
|
||||
|
||||
**No status column on subscribers.** Existence = waiting; deletion = resolved (with the resolution mode encoded in the notification row created at deletion time, except for the canceller path).
|
||||
|
||||
**Gate composition with the trust-guard.** The pre-handshake trust-guard ("inbound `peer_approval_requests` exists → refuse outbound with `'rejected'`") is preserved unchanged and runs after the gate. The trust-guard query was narrowed to `direction='inbound'` so the gate's own outbound queue rows don't false-positive block their own approval path. The two layers compose cleanly: the gate fires earlier when there's no peer row at all; the trust-guard fires later when an inbound row exists.
|
||||
|
||||
**Caller summary** (intent each call site declares):
|
||||
|
||||
| Site | Intent | On `'admin_required'` |
|
||||
|---|---|---|
|
||||
| `routes/social.ts` (friend-add) | `user_action` reason `friend_add` target `name@domain` | 409 `peer_pending_local_admin` |
|
||||
| `routes/federation.ts` (`/peer/ensure`) | `user_action` reason `friend_add` (default; client-supplied reason is ignored today) | response `peeringStatus: 'admin_required'` |
|
||||
| `utils/federationOutbox.ts` (`sendCallRelay` no-active-peer) | `system` | `CallRelayFailureReason.peer_admin_required` |
|
||||
| `utils/federationWorker.ts` (`resolvePendingPeers`) | n/a | operates only on already-existing pending rows; gate is unreachable from this path |
|
||||
|
||||
Admin-initiated paths (`/peer/initiate`, `/approve`) do NOT call `ensurePeered`. They issue their own `fetch` and run their own activation logic. The gate does not affect admin power; admins retain full ability to pre-peer or approve outbound regardless of the setting.
|
||||
|
||||
### Admin Endpoints
|
||||
|
||||
|
||||
@@ -192,6 +192,9 @@ reason: `'displaced'` (new tab) | `'session_closed'`
|
||||
| type | fields | scope |
|
||||
|------|--------|-------|
|
||||
| `federation_file_rejected` | messageId, dmChannelId, attachmentId, affectedUsers[] | DM members |
|
||||
| `federation_approval_request_received` | — (refetch trigger; payload: `{ type }`) | admins. Fires for **both** inbound peering requests (remote → us) AND outbound queue creation when the [Outbound Peering Gate](federation.md#outbound-peering-gate) creates a `peer_approval_requests` row in response to a user_action. Payload shape unchanged from the inbound-only behavior; only the firing surface widened. |
|
||||
| `peering_subscription_changed` | — (refetch trigger; payload: `{ type }`) | the subscribing user (all of their connected sessions). Fires when a `peer_approval_subscribers` row belonging to the user is created, modified, or deleted (gate fan-in, user cancel, parent cascade). Client refetches `GET /api/federation/peering-subscriptions`. |
|
||||
| `peering_notification_received` | `{ type, kind: 'approved' \| 'denied' \| 'expired' }` | the user the notification belongs to. Fires when a `peer_approval_notifications` row is created (`onPeerActivated` outbound fanout, outbound `/deny` fanout, janitor outbound expiry). Client refetches `GET /api/federation/peering-notifications` and may surface a transient toast for online users. |
|
||||
|
||||
**S2S relay-only event (not a direct client WS event):**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user