Merge branch 'feat/outbound-peering-gate'

This commit is contained in:
Jannis Braun
2026-04-26 23:30:44 +02:00
37 changed files with 8577 additions and 306 deletions
+21 -12
View File
@@ -361,26 +361,31 @@ The temporary password is shown exactly once in the admin UI -- the UsersPanel d
### Peering Approval Requests ### 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[] GET /api/federation/approval-requests → { requests: ApprovalRequestSummary[] }
POST /api/federation/approval-requests/:id/approve → { success: boolean } POST /api/federation/approval-requests/:id/approve → { success, peerStatus?, peer? }
POST /api/federation/approval-requests/:id/deny → { success: boolean } 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 ```typescript
{ {
id: string; // Snowflake id: string;
origin: string; // Requesting instance URL direction: 'inbound' | 'outbound';
origin: string;
instanceName: string | null; instanceName: string | null;
requestedAt: number; // Epoch ms requestedAt: number;
expiresAt: number; // Epoch ms (requestedAt + 30 days) 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 #### 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. - 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. - Peers with status `'revoked'` are filtered out of the visible list.
- Revoke calls `api.federation.revokePeer(peerId)` and removes from local list. - Revoke calls `api.federation.revokePeer(peerId)` and removes from local list.
+99
View File
@@ -142,6 +142,7 @@ GET /social/search ?q= → { users[] }
| 404 | `user_not_found` | Remote lookup returned 404 (no such user, or tombstoned) | | 404 | `user_not_found` | Remote lookup returned 404 (no such user, or tombstoned) |
| 409 | `already_friends` | Friendship row already exists | | 409 | `already_friends` | Friendship row already exists |
| 409 | `peer_pending_approval` | Remote admin needs to approve the peering relationship | | 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 | `peer_pending` | Peer handshake in flight |
| 409 | `incoming_request_exists` | Opposite-direction pending request exists; response includes `requestId` for deep-link | | 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 | | 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. **`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 ## Utilities (`routes/utils.ts`) — auth required
``` ```
GET /utils/metadata ?url= → { title?, description?, image?, siteName? } GET /utils/metadata ?url= → { title?, description?, image?, siteName? }
+52 -1
View File
@@ -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: Client-side and S2S federation serve different purposes:
+50 -5
View File
@@ -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). | | 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 ### 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 | | Column | Type | Default | Notes |
|--------|------|---------|-------| |--------|------|---------|-------|
| id | text PK | | Snowflake | | id | text PK | | Snowflake |
| origin | text NOT NULL UNIQUE | | Requesting instance's origin URL | | origin | text NOT NULL | | Requesting / target instance's origin URL. UNIQUE per `direction` (composite UNIQUE `(origin, direction)`). |
| instanceName | text | | Instance name sent by requester | | direction | text NOT NULL | `'inbound'` | `'inbound'` (remote → us) or `'outbound'` (us → remote, gate-created on user_action). Migration backfills existing rows to `'inbound'`. |
| hmacSecret | text NOT NULL | | Requester's HMAC secret; used to sign denial notification | | 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 | | requestedAt | integer NOT NULL | | Epoch ms |
| expiresAt | integer NOT NULL | | Epoch ms; requestedAt + 30 days | | 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 ### federation_outbox
UNIQUE: (peerId, entityId) UNIQUE: (peerId, entityId)
+94 -10
View File
@@ -145,20 +145,38 @@ When `autoAcceptPeering` is `false` and an instance calls `POST /api/federation/
- `requested_at` / `expires_at` — Epoch ms; expiry is `requested_at + 30 days` - `requested_at` / `expires_at` — Epoch ms; expiry is `requested_at + 30 days`
- `approval_token` — Single-use 64-hex-char random token issued in the 202 response and forwarded by `/approve`. See [Approval Token Verification](#approval-token-verification). - `approval_token` — Single-use 64-hex-char random token issued in the 202 response and forwarded by `/approve`. See [Approval Token Verification](#approval-token-verification).
**Approval flow** — Admin approves via `POST /api/federation/approval-requests/:id/approve`: **Approval flow** — Admin approves via `POST /api/federation/approval-requests/:id/approve`. The handler dispatches on `peer_approval_requests.direction`:
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`: *Inbound* (remote asked to peer with us):
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 (forwarding the stored `approvalToken` per [Approval Token Verification](#approval-token-verification))
3. On 200 the local peer becomes `active`; on 202 the peer transitions to `awaiting_approval` (mutual-gate); the `peer_approval_requests` row is deleted in both cases
*Outbound* (local users asked us to peer with a remote — created by the gate in `ensurePeered`):
1. A fresh `federationPeer` row is created with status `pending` (HMAC generated at this moment — outbound queue rows store `hmac_secret = NULL`)
2. `peer/accept` is sent to the remote with no `approvalToken` (we are the initiator with no prior token from this remote)
3. On 200 the peer is promoted to `active`. `onPeerActivated` then fires `fanoutOutboundSubscribers` (see [Outbound peering gate](#outbound-peering-gate)) which writes `kind='approved'` notifications for each subscriber and cascade-deletes the parent `peer_approval_requests` row. The handler does NOT duplicate this cleanup.
4. On 202 the peer transitions to `awaiting_approval`, captures the returned `approvalToken`, and the queue row + subscribers are LEFT INTACT — they wait for the eventual remote-admin approval. `onPeerActivated` is NOT called on this path.
5. On 4xx/5xx/network error the peer row is deleted; the queue row is left intact so the admin can retry. Response status is `502`/`503`/`504` accordingly.
6. Response body shape: `{ success, peerStatus: 'active' | 'awaiting_approval', peer? }`. The `peerStatus` field is the outbound-only signal.
**Denial flow** — Admin denies via `POST /api/federation/approval-requests/:id/deny`. The handler dispatches on direction:
*Inbound*:
1. Server sends `POST {origin}/api/federation/peer/denied` signed with the requester's `hmac_secret` (from the approval request row) 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` 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 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 4. The `peer_approval_requests` row is deleted
*Outbound*:
1. For each row in `peer_approval_subscribers`, a `kind='denied'` notification is inserted into `peer_approval_notifications` and a `peering_notification_received` WS event is sent to the user
2. The parent `peer_approval_requests` row is deleted (cascade clears subscribers)
3. No remote network call — the remote never knew we were considering this peer
4. `federation_peers_changed` is broadcast to admins so the queue UI refreshes
**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. **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 ### Approval Token Verification
@@ -178,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. **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 ### Admin Endpoints
@@ -193,9 +277,9 @@ The pre-handshake guard above closes the most reliable trigger but cannot preven
| `/api/federation/peers/:id/permanent` | DELETE | JWT + admin | Hard-delete revoked peer record | | `/api/federation/peers/:id/permanent` | DELETE | JWT + admin | Hard-delete revoked peer record |
| `/api/federation/peers/:id/reset` | POST | JWT + admin | Delete peer record (cascade-deletes outbox). Only admissible in `needs_attention` state. | | `/api/federation/peers/:id/reset` | POST | JWT + admin | Delete peer record (cascade-deletes outbox). Only admissible in `needs_attention` state. |
| `/api/federation/peers/:id/rotate` | POST | JWT + admin | Trigger immediate secret rotation | | `/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` | GET | JWT + admin | List pending peering approval requests (inbound + outbound). Outbound rows include `subscribers: ApprovalRequestSubscriberSummary[]` (possibly empty). Inbound rows omit `subscribers`. Each row carries `direction: 'inbound' \| 'outbound'`. |
| `/api/federation/approval-requests/:id/approve` | POST | JWT + admin | Approve request, initiate handshake | | `/api/federation/approval-requests/:id/approve` | POST | JWT + admin | Approve request — direction-branched (see Approval flow above) |
| `/api/federation/approval-requests/:id/deny` | POST | JWT + admin | Deny request, notify requester | | `/api/federation/approval-requests/:id/deny` | POST | JWT + admin | Deny request — direction-branched (see Denial flow above) |
**`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`. **`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`.
+1
View File
@@ -300,6 +300,7 @@ As of 2026-04-25, the sender's home server owns the entire federated friend-add
- `'pending'` + peer row `awaiting_approval` (re-queried after the call) → 409 `peer_pending_approval` - `'pending'` + peer row `awaiting_approval` (re-queried after the call) → 409 `peer_pending_approval`
- `'rejected'` → 403 `peer_rejected` - `'rejected'` → 403 `peer_rejected`
- `'failed'` → 503 `peer_unreachable` - `'failed'` → 503 `peer_unreachable`
- `'admin_required'` (gate fired locally) → 409 `peer_pending_local_admin` — your own admin must approve before we reach out
5. **lookupRemoteUser(peerOrigin, baseName)** — POSTs HMAC-signed `{ username }` to `peerOrigin/api/federation/users/lookup`. Result mapping: 5. **lookupRemoteUser(peerOrigin, baseName)** — POSTs HMAC-signed `{ username }` to `peerOrigin/api/federation/users/lookup`. Result mapping:
- `not_found` → 404 `user_not_found` - `not_found` → 404 `user_not_found`
- `unreachable` → 503 `peer_unreachable` - `unreachable` → 503 `peer_unreachable`
+3
View File
@@ -192,6 +192,9 @@ reason: `'displaced'` (new tab) | `'session_closed'`
| type | fields | scope | | type | fields | scope |
|------|--------|-------| |------|--------|-------|
| `federation_file_rejected` | messageId, dmChannelId, attachmentId, affectedUsers[] | DM members | | `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):** **S2S relay-only event (not a direct client WS event):**
@@ -0,0 +1,50 @@
CREATE TABLE `peer_approval_notifications` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`kind` text NOT NULL,
`peer_origin` text NOT NULL,
`trigger_reason` text NOT NULL,
`trigger_target` text NOT NULL,
`created_at` integer NOT NULL,
`read_at` integer,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `peer_approval_subscribers` (
`id` text PRIMARY KEY NOT NULL,
`request_id` text NOT NULL,
`user_id` text NOT NULL,
`trigger_reason` text NOT NULL,
`trigger_target` text NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`request_id`) REFERENCES `peer_approval_requests`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_peer_approval_notifications_user_id` ON `peer_approval_notifications` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_peer_approval_subscribers_user_id` ON `peer_approval_subscribers` (`user_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `peer_approval_subscribers_request_id_user_id_trigger_reason_trigger_target_unique` ON `peer_approval_subscribers` (`request_id`,`user_id`,`trigger_reason`,`trigger_target`);--> statement-breakpoint
-- Recreate peer_approval_requests:
-- - add direction column (default 'inbound') so existing rows classify correctly
-- - relax hmac_secret to nullable (outbound rows generate fresh on approval)
-- - drop UNIQUE(origin); add UNIQUE(origin, direction)
-- - add CHECK enforcing inbound rows always carry hmac_secret
CREATE TABLE `__new_peer_approval_requests` (
`id` text PRIMARY KEY NOT NULL,
`origin` text NOT NULL,
`direction` text DEFAULT 'inbound' NOT NULL,
`instance_name` text,
`hmac_secret` text,
`requested_at` integer NOT NULL,
`expires_at` integer NOT NULL,
`approval_token` text,
CHECK (
(direction = 'inbound' AND hmac_secret IS NOT NULL)
OR (direction = 'outbound')
)
);--> statement-breakpoint
INSERT INTO `__new_peer_approval_requests` (`id`, `origin`, `direction`, `instance_name`, `hmac_secret`, `requested_at`, `expires_at`, `approval_token`)
SELECT `id`, `origin`, 'inbound', `instance_name`, `hmac_secret`, `requested_at`, `expires_at`, `approval_token` FROM `peer_approval_requests`;--> statement-breakpoint
DROP TABLE `peer_approval_requests`;--> statement-breakpoint
ALTER TABLE `__new_peer_approval_requests` RENAME TO `peer_approval_requests`;--> statement-breakpoint
CREATE UNIQUE INDEX `peer_approval_requests_origin_direction_unique` ON `peer_approval_requests` (`origin`,`direction`);
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,13 @@
"when": 1777196627239, "when": 1777196627239,
"tag": "0002_peer_approval_token", "tag": "0002_peer_approval_token",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1777229997210,
"tag": "0003_brave_inhumans",
"breakpoints": true
} }
] ]
} }
+36 -3
View File
@@ -377,15 +377,48 @@ export const federationPeers = sqliteTable('federation_peers', {
approvalToken: text('approval_token'), approvalToken: text('approval_token'),
}); });
// SQL-level CHECK constraint enforces (direction='inbound' → hmac_secret NOT NULL).
// See packages/server/drizzle/0003_brave_inhumans.sql. drizzle-kit cannot represent
// CHECK constraints in its snapshot, so any future migration that recreates this
// table MUST re-add the CHECK clause by hand. The snapshot WILL silently drop it
// otherwise.
export const peerApprovalRequests = sqliteTable('peer_approval_requests', { export const peerApprovalRequests = sqliteTable('peer_approval_requests', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
origin: text('origin').notNull().unique(), origin: text('origin').notNull(),
direction: text('direction', { enum: ['inbound', 'outbound'] }).notNull().default('inbound'),
instanceName: text('instance_name'), instanceName: text('instance_name'),
hmacSecret: text('hmac_secret').notNull(), hmacSecret: text('hmac_secret'),
requestedAt: integer('requested_at').notNull(), requestedAt: integer('requested_at').notNull(),
expiresAt: integer('expires_at').notNull(), expiresAt: integer('expires_at').notNull(),
approvalToken: text('approval_token'), approvalToken: text('approval_token'),
}); }, (table) => ({
uniqOriginDirection: unique().on(table.origin, table.direction),
}));
export const peerApprovalSubscribers = sqliteTable('peer_approval_subscribers', {
id: text('id').primaryKey(),
requestId: text('request_id').notNull().references(() => peerApprovalRequests.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
triggerReason: text('trigger_reason').notNull(),
triggerTarget: text('trigger_target').notNull(),
createdAt: integer('created_at').notNull(),
}, (table) => ({
uniqSubscription: unique().on(table.requestId, table.userId, table.triggerReason, table.triggerTarget),
userIdx: index('idx_peer_approval_subscribers_user_id').on(table.userId),
}));
export const peerApprovalNotifications = sqliteTable('peer_approval_notifications', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
kind: text('kind', { enum: ['approved', 'denied', 'expired'] }).notNull(),
peerOrigin: text('peer_origin').notNull(),
triggerReason: text('trigger_reason').notNull(),
triggerTarget: text('trigger_target').notNull(),
createdAt: integer('created_at').notNull(),
readAt: integer('read_at'),
}, (table) => ({
userIdx: index('idx_peer_approval_notifications_user_id').on(table.userId),
}));
export const federationOutbox = sqliteTable('federation_outbox', { export const federationOutbox = sqliteTable('federation_outbox', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
@@ -0,0 +1,471 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { eq } from 'drizzle-orm';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { setWorkerId } from '../utils/snowflake.js';
setWorkerId(1);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
vi.mock('../config.js', () => ({
config: {
domain: 'local.example',
port: 3000,
host: '0.0.0.0',
jwtSecret: 'test-secret-12345678901234567890123456789012',
maxUploadSize: 100 * 1024 * 1024,
registrationOpen: true,
},
}));
vi.mock('../utils/auth.js', () => ({
authenticate: async (req: { userId?: string }) => {
req.userId = 'admin-user';
},
requireAdmin: async () => {},
}));
vi.mock('../utils/federationAuth.js', async () => {
const actual = await vi.importActual<typeof import('../utils/federationAuth.js')>('../utils/federationAuth.js');
return {
...actual,
getOurOrigin: () => 'https://local.example',
generateHmacSecret: () => 'mock-generated-secret',
};
});
const sentToUser = vi.fn();
const sentToAdmins = vi.fn();
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToAdmins: sentToAdmins,
getAllOnlineUserIds: () => [],
sendToUser: sentToUser,
sendToDmMembers: vi.fn(),
},
}));
// Mock the activation module entirely. The route handler is responsible for
// CALLING onPeerActivated on the 200 path; we assert that here. The fanout
// behavior itself is covered by federationPeerActivation.outboundFanout.test.ts
// (Task 6) — keeping that separation avoids running real network sync from
// inside a route test.
const onPeerActivatedMock = vi.fn(async () => undefined);
vi.mock('../utils/federationPeerActivation.js', () => ({
onPeerActivated: onPeerActivatedMock,
onPeerDeactivated: vi.fn(async () => undefined),
}));
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
const statements = sqlText.split(/-->\s*statement-breakpoint/);
for (const stmt of statements) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedInstanceSettings(): void {
testDb.insert(schema.instanceSettings).values({
id: 1,
instanceName: 'Local Backspace',
autoAcceptPeering: 0,
registrationOpen: 1,
updatedAt: Date.now(),
}).run();
}
function seedUser(id: string, username: string): void {
testDb.insert(schema.users).values({
id,
username,
passwordHash: 'x',
displayName: username,
createdAt: Date.now(),
}).run();
}
function seedOutboundRequest(opts: {
id: string;
origin: string;
instanceName?: string | null;
subscribers: Array<{ userId: string; reason: 'friend_add' | 'space_join' | 'direct_message'; target: string }>;
}): void {
const now = Date.now();
testDb.insert(schema.peerApprovalRequests).values({
id: opts.id,
origin: opts.origin,
direction: 'outbound',
instanceName: opts.instanceName ?? null,
hmacSecret: null,
requestedAt: now,
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
approvalToken: null,
}).run();
for (const sub of opts.subscribers) {
testDb.insert(schema.peerApprovalSubscribers).values({
id: `sub-${opts.id}-${sub.userId}`,
requestId: opts.id,
userId: sub.userId,
triggerReason: sub.reason,
triggerTarget: sub.target,
createdAt: now,
}).run();
}
}
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
const { federationRoutes } = await import('./federation.js');
await app.register(federationRoutes);
await app.ready();
return app;
}
describe('POST /api/federation/approval-requests/:id/approve — outbound direction', () => {
let app: FastifyInstance;
beforeEach(async () => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
seedInstanceSettings();
seedUser('alice', 'alice');
seedUser('bob', 'bob');
sentToUser.mockClear();
sentToAdmins.mockClear();
onPeerActivatedMock.mockClear();
app = await buildApp();
});
afterEach(() => {
vi.restoreAllMocks();
sqlite.close();
});
// Test 13: 200 outbound activates peer + delegates fanout to onPeerActivated.
it('200 from remote → peer becomes active; delegates fanout to onPeerActivated; handler does NOT manually clean subscribers', async () => {
seedOutboundRequest({
id: 'req-out-200',
origin: 'https://remote.example',
instanceName: 'Remote Inst',
subscribers: [
{ userId: 'alice', reason: 'friend_add', target: 'someone@remote.example' },
{ userId: 'bob', reason: 'friend_add', target: 'other@remote.example' },
],
});
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({ accepted: true, instanceName: 'Remote Backspace' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
const response = await app.inject({
method: 'POST',
url: '/api/federation/approval-requests/req-out-200/approve',
});
expect(response.statusCode).toBe(200);
const body = response.json() as { success: boolean; peerStatus: string };
expect(body.success).toBe(true);
expect(body.peerStatus).toBe('active');
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
expect(peer?.status).toBe('active');
expect(peer?.instanceName).toBe('Remote Backspace');
expect(peer?.approvalToken).toBeNull();
// Handler delegates fanout: onPeerActivated MUST be called with the new peer's id.
expect(onPeerActivatedMock).toHaveBeenCalledTimes(1);
expect(onPeerActivatedMock).toHaveBeenCalledWith(peer!.id, 'approval_handshake');
// The handler must NOT pre-emptively clean up subscribers — that's
// onPeerActivated's job. Since we mocked onPeerActivated, the parent +
// subscribers should still be present here. (In production, the real
// onPeerActivated then cascades them; covered by Task 6's fanout test.)
expect(testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, 'req-out-200')).get()).toBeDefined();
expect(testDb.select().from(schema.peerApprovalSubscribers)
.where(eq(schema.peerApprovalSubscribers.requestId, 'req-out-200')).all()).toHaveLength(2);
// No notifications written by the handler directly.
expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0);
// No peering_notification_received fired by the handler.
const peeringEvents = sentToUser.mock.calls.filter(call => {
const ev = call[1] as { type?: string };
return ev?.type === 'peering_notification_received';
});
expect(peeringEvents).toHaveLength(0);
});
// Test 14: 202 outbound transitions to awaiting_approval and leaves queue intact.
it('202 from remote → peer becomes awaiting_approval, captures approvalToken, queue + subscribers REMAIN, onPeerActivated NOT called', async () => {
seedOutboundRequest({
id: 'req-out-202',
origin: 'https://remote.example',
subscribers: [
{ userId: 'alice', reason: 'friend_add', target: 'someone@remote.example' },
],
});
const remoteToken = 'a'.repeat(64);
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({ queued: true, approvalToken: remoteToken }),
{ status: 202, headers: { 'Content-Type': 'application/json' } },
),
);
const response = await app.inject({
method: 'POST',
url: '/api/federation/approval-requests/req-out-202/approve',
});
expect(response.statusCode).toBe(200);
const body = response.json() as { success: boolean; peerStatus: string };
expect(body.success).toBe(true);
expect(body.peerStatus).toBe('awaiting_approval');
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
expect(peer?.status).toBe('awaiting_approval');
expect(peer?.approvalToken).toBe(remoteToken);
// Outbound queue row + subscribers REMAIN — they wait for full activation.
const stillQueued = testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, 'req-out-202')).get();
expect(stillQueued).toBeDefined();
expect(stillQueued?.direction).toBe('outbound');
const stillSubscribed = testDb.select().from(schema.peerApprovalSubscribers)
.where(eq(schema.peerApprovalSubscribers.requestId, 'req-out-202')).all();
expect(stillSubscribed).toHaveLength(1);
// onPeerActivated must NOT be called — peer is not yet active.
expect(onPeerActivatedMock).not.toHaveBeenCalled();
// No notifications written.
expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0);
// No peering_notification_received fired.
const peeringEvents = sentToUser.mock.calls.filter(call => {
const ev = call[1] as { type?: string };
return ev?.type === 'peering_notification_received';
});
expect(peeringEvents).toHaveLength(0);
});
// Test 15: network error returns 503; peer cleaned up; queue intact.
it('network error from remote → 503; peer row cleaned up; queue + subscribers REMAIN; onPeerActivated NOT called', async () => {
seedOutboundRequest({
id: 'req-out-net',
origin: 'https://remote.example',
subscribers: [
{ userId: 'alice', reason: 'friend_add', target: 'someone@remote.example' },
],
});
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('fetch failed: connection refused'));
const response = await app.inject({
method: 'POST',
url: '/api/federation/approval-requests/req-out-net/approve',
});
expect(response.statusCode).toBe(503);
// Peer row cleaned up.
expect(testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get()).toBeUndefined();
// Queue + subscribers UNTOUCHED for admin retry.
expect(testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, 'req-out-net')).get()).toBeDefined();
expect(testDb.select().from(schema.peerApprovalSubscribers)
.where(eq(schema.peerApprovalSubscribers.requestId, 'req-out-net')).all()).toHaveLength(1);
expect(onPeerActivatedMock).not.toHaveBeenCalled();
expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0);
});
it('5xx from remote → 502; peer row cleaned up; queue + subscribers REMAIN', async () => {
seedOutboundRequest({
id: 'req-out-500',
origin: 'https://remote.example',
subscribers: [
{ userId: 'alice', reason: 'friend_add', target: 'someone@remote.example' },
],
});
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ error: 'remote boom' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
}),
);
const response = await app.inject({
method: 'POST',
url: '/api/federation/approval-requests/req-out-500/approve',
});
expect(response.statusCode).toBe(502);
const body = response.json() as { remoteStatus?: number };
expect(body.remoteStatus).toBe(500);
expect(testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get()).toBeUndefined();
expect(testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, 'req-out-500')).get()).toBeDefined();
expect(testDb.select().from(schema.peerApprovalSubscribers)
.where(eq(schema.peerApprovalSubscribers.requestId, 'req-out-500')).all()).toHaveLength(1);
});
it('does NOT forward approvalToken in outbound /peer/accept body (we hold no remote token)', async () => {
seedOutboundRequest({
id: 'req-out-noforward',
origin: 'https://remote.example',
subscribers: [
{ userId: 'alice', reason: 'friend_add', target: 'x@remote.example' },
],
});
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ accepted: true, instanceName: 'R' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
await app.inject({
method: 'POST',
url: '/api/federation/approval-requests/req-out-noforward/approve',
});
const init = fetchSpy.mock.calls[0]?.[1];
const body = JSON.parse(init?.body as string) as { approvalToken?: string; sourceOrigin?: string; hmacSecret?: string };
expect(body.approvalToken).toBeUndefined();
expect(body.sourceOrigin).toBe('https://local.example');
expect(body.hmacSecret).toBe('mock-generated-secret');
});
});
describe('GET /api/federation/approval-requests — direction + outbound subscribers in response shape', () => {
let app: FastifyInstance;
beforeEach(async () => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
seedInstanceSettings();
seedUser('alice', 'alice');
seedUser('bob', 'bob');
app = await buildApp();
});
afterEach(() => {
vi.restoreAllMocks();
sqlite.close();
});
it('inbound rows have no subscribers field; outbound rows include subscriber summaries with username', async () => {
const now = Date.now();
testDb.insert(schema.peerApprovalRequests).values({
id: 'req-inbound',
origin: 'https://inbound.example',
direction: 'inbound',
instanceName: 'Inbound',
hmacSecret: 'their-secret',
requestedAt: now,
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
}).run();
seedOutboundRequest({
id: 'req-outbound',
origin: 'https://outbound.example',
instanceName: null,
subscribers: [
{ userId: 'alice', reason: 'friend_add', target: 'a@outbound.example' },
{ userId: 'bob', reason: 'space_join', target: 'invite-code-xyz' },
],
});
const response = await app.inject({
method: 'GET',
url: '/api/federation/approval-requests',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
requests: Array<{
id: string;
direction: string;
subscribers?: Array<{ userId: string; username: string; triggerReason: string; triggerTarget: string }>;
}>;
};
const inbound = body.requests.find(r => r.id === 'req-inbound');
const outbound = body.requests.find(r => r.id === 'req-outbound');
expect(inbound).toBeDefined();
expect(inbound?.direction).toBe('inbound');
expect(inbound?.subscribers).toBeUndefined();
expect(outbound).toBeDefined();
expect(outbound?.direction).toBe('outbound');
expect(outbound?.subscribers).toBeDefined();
expect(outbound?.subscribers).toHaveLength(2);
const usernames = new Set(outbound!.subscribers!.map(s => s.username));
expect(usernames).toEqual(new Set(['alice', 'bob']));
const reasons = new Set(outbound!.subscribers!.map(s => s.triggerReason));
expect(reasons).toEqual(new Set(['friend_add', 'space_join']));
});
it('outbound row with zero subscribers returns subscribers: [] (not undefined)', async () => {
const now = Date.now();
testDb.insert(schema.peerApprovalRequests).values({
id: 'req-empty-out',
origin: 'https://lonely.example',
direction: 'outbound',
instanceName: null,
hmacSecret: null,
requestedAt: now,
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
}).run();
const response = await app.inject({
method: 'GET',
url: '/api/federation/approval-requests',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
requests: Array<{ id: string; direction: string; subscribers?: unknown }>;
};
const row = body.requests.find(r => r.id === 'req-empty-out');
expect(row?.direction).toBe('outbound');
expect(Array.isArray(row?.subscribers)).toBe(true);
expect(row?.subscribers).toEqual([]);
});
});
@@ -0,0 +1,387 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { eq } from 'drizzle-orm';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { setWorkerId } from '../utils/snowflake.js';
setWorkerId(1);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
vi.mock('../config.js', () => ({
config: {
domain: 'local.example',
port: 3000,
host: '0.0.0.0',
jwtSecret: 'test-secret-12345678901234567890123456789012',
maxUploadSize: 100 * 1024 * 1024,
registrationOpen: true,
},
}));
vi.mock('../utils/auth.js', () => ({
authenticate: async (req: { userId?: string }) => {
req.userId = 'admin-user';
},
requireAdmin: async () => {},
}));
vi.mock('../utils/federationAuth.js', async () => {
const actual = await vi.importActual<typeof import('../utils/federationAuth.js')>('../utils/federationAuth.js');
return {
...actual,
getOurOrigin: () => 'https://local.example',
generateHmacSecret: () => 'mock-generated-secret',
};
});
const sentToUser = vi.fn();
const sentToAdmins = vi.fn();
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToAdmins: sentToAdmins,
getAllOnlineUserIds: () => [],
sendToUser: sentToUser,
sendToDmMembers: vi.fn(),
},
}));
const onPeerActivatedMock = vi.fn(async () => undefined);
vi.mock('../utils/federationPeerActivation.js', () => ({
onPeerActivated: onPeerActivatedMock,
onPeerDeactivated: vi.fn(async () => undefined),
}));
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
const statements = sqlText.split(/-->\s*statement-breakpoint/);
for (const stmt of statements) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedInstanceSettings(): void {
testDb.insert(schema.instanceSettings).values({
id: 1,
instanceName: 'Local Backspace',
autoAcceptPeering: 0,
registrationOpen: 1,
updatedAt: Date.now(),
}).run();
}
function seedUser(id: string, username: string): void {
testDb.insert(schema.users).values({
id,
username,
passwordHash: 'x',
displayName: username,
createdAt: Date.now(),
}).run();
}
function seedOutboundRequest(opts: {
id: string;
origin: string;
subscribers: Array<{ userId: string; reason: 'friend_add' | 'space_join' | 'direct_message'; target: string }>;
}): void {
const now = Date.now();
testDb.insert(schema.peerApprovalRequests).values({
id: opts.id,
origin: opts.origin,
direction: 'outbound',
instanceName: null,
hmacSecret: null,
requestedAt: now,
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
approvalToken: null,
}).run();
for (const sub of opts.subscribers) {
testDb.insert(schema.peerApprovalSubscribers).values({
id: `sub-${opts.id}-${sub.userId}`,
requestId: opts.id,
userId: sub.userId,
triggerReason: sub.reason,
triggerTarget: sub.target,
createdAt: now,
}).run();
}
}
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
const { federationRoutes } = await import('./federation.js');
await app.register(federationRoutes);
await app.ready();
return app;
}
describe('POST /api/federation/approval-requests/:id/deny — outbound direction', () => {
let app: FastifyInstance;
beforeEach(async () => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
seedInstanceSettings();
seedUser('alice', 'alice');
seedUser('bob', 'bob');
sentToUser.mockClear();
sentToAdmins.mockClear();
onPeerActivatedMock.mockClear();
app = await buildApp();
});
afterEach(() => {
vi.restoreAllMocks();
sqlite.close();
});
// Test 16: outbound deny fans out denied notifications + cascade-deletes.
it('outbound deny → writes denied notifications for each subscriber, sends WS to each, cascade-deletes parent + subscribers, broadcasts admin event', async () => {
seedOutboundRequest({
id: 'req-out-deny',
origin: 'https://remote.example',
subscribers: [
{ userId: 'alice', reason: 'friend_add', target: 'someone@remote.example' },
{ userId: 'bob', reason: 'space_join', target: 'invite-xyz' },
],
});
// No fetch should be invoked — outbound deny has no remote network call.
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const response = await app.inject({
method: 'POST',
url: '/api/federation/approval-requests/req-out-deny/deny',
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
// No remote network call.
expect(fetchSpy).not.toHaveBeenCalled();
// Two denied notifications written.
const notifications = testDb.select().from(schema.peerApprovalNotifications).all();
expect(notifications).toHaveLength(2);
expect(notifications.every(n => n.kind === 'denied')).toBe(true);
expect(notifications.every(n => n.peerOrigin === 'https://remote.example')).toBe(true);
expect(notifications.every(n => n.readAt === null)).toBe(true);
const userIds = new Set(notifications.map(n => n.userId));
expect(userIds).toEqual(new Set(['alice', 'bob']));
// Trigger reason + target captured per row.
const aliceN = notifications.find(n => n.userId === 'alice');
expect(aliceN?.triggerReason).toBe('friend_add');
expect(aliceN?.triggerTarget).toBe('someone@remote.example');
const bobN = notifications.find(n => n.userId === 'bob');
expect(bobN?.triggerReason).toBe('space_join');
expect(bobN?.triggerTarget).toBe('invite-xyz');
// Parent + subscribers gone (cascade).
expect(testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, 'req-out-deny')).get()).toBeUndefined();
expect(testDb.select().from(schema.peerApprovalSubscribers)
.where(eq(schema.peerApprovalSubscribers.requestId, 'req-out-deny')).all()).toHaveLength(0);
// Each subscriber received a peering_notification_received WS.
const peeringEvents = sentToUser.mock.calls.filter(call => {
const ev = call[1] as { type?: string; kind?: string };
return ev?.type === 'peering_notification_received' && ev?.kind === 'denied';
});
expect(peeringEvents).toHaveLength(2);
// Admins notified that queue changed.
const adminEvents = sentToAdmins.mock.calls.filter(call => {
const ev = call[0] as { type?: string };
return ev?.type === 'federation_peers_changed';
});
expect(adminEvents.length).toBeGreaterThanOrEqual(1);
// No federation_peers row created — outbound deny has no peer to mark rejected.
expect(testDb.select().from(schema.federationPeers).all()).toHaveLength(0);
});
it('outbound deny with zero subscribers still cascade-deletes parent and broadcasts admin event', async () => {
const now = Date.now();
testDb.insert(schema.peerApprovalRequests).values({
id: 'req-out-empty',
origin: 'https://lonely.example',
direction: 'outbound',
instanceName: null,
hmacSecret: null,
requestedAt: now,
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
approvalToken: null,
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/federation/approval-requests/req-out-empty/deny',
});
expect(response.statusCode).toBe(200);
expect(testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, 'req-out-empty')).get()).toBeUndefined();
expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0);
const adminEvents = sentToAdmins.mock.calls.filter(call => {
const ev = call[0] as { type?: string };
return ev?.type === 'federation_peers_changed';
});
expect(adminEvents.length).toBeGreaterThanOrEqual(1);
});
it('non-existent id returns 404', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/federation/approval-requests/does-not-exist/deny',
});
expect(response.statusCode).toBe(404);
});
});
// Test 17: regression — inbound paths still behave as before the direction split.
describe('Inbound regression after direction split', () => {
let app: FastifyInstance;
beforeEach(async () => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
seedInstanceSettings();
seedUser('alice', 'alice');
sentToUser.mockClear();
sentToAdmins.mockClear();
onPeerActivatedMock.mockClear();
app = await buildApp();
});
afterEach(() => {
vi.restoreAllMocks();
sqlite.close();
});
function seedInboundRequest(id: string): void {
const now = Date.now();
testDb.insert(schema.peerApprovalRequests).values({
id,
origin: 'https://inbound.example',
direction: 'inbound',
instanceName: 'Inbound',
hmacSecret: 'their-secret',
requestedAt: now,
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
}).run();
}
it('/approve on inbound row → existing 200 path activates peer (preserved verbatim)', async () => {
seedInboundRequest('req-in-approve');
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ accepted: true, instanceName: 'Inbound Backspace' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const response = await app.inject({
method: 'POST',
url: '/api/federation/approval-requests/req-in-approve/approve',
});
expect(response.statusCode).toBe(200);
const body = response.json() as { success: boolean; peer?: { status?: string } };
expect(body.success).toBe(true);
// Inbound preserves the existing response shape: `peer` is included,
// `peerStatus` is NOT (that's outbound's signal).
expect(body.peer).toBeDefined();
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://inbound.example')).get();
expect(peer?.status).toBe('active');
expect(peer?.instanceName).toBe('Inbound Backspace');
// Inbound 200 path deletes the queue row directly (not via onPeerActivated).
expect(testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, 'req-in-approve')).get()).toBeUndefined();
// Still calls onPeerActivated for sync-pull / outbox reset.
expect(onPeerActivatedMock).toHaveBeenCalledWith(peer!.id, 'approval_handshake');
});
it('/deny on inbound row → calls remote /peer/denied, marks peer rejected, deletes queue row', async () => {
seedInboundRequest('req-in-deny');
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const response = await app.inject({
method: 'POST',
url: '/api/federation/approval-requests/req-in-deny/deny',
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
// Remote /peer/denied invoked.
expect(fetchSpy).toHaveBeenCalledTimes(1);
const url = fetchSpy.mock.calls[0]?.[0] as string;
expect(url).toBe('https://inbound.example/api/federation/peer/denied');
// Local peer row inserted as 'rejected'.
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://inbound.example')).get();
expect(peer?.status).toBe('rejected');
// Queue row deleted.
expect(testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, 'req-in-deny')).get()).toBeUndefined();
// No subscriber notifications (those are outbound-only).
expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0);
});
it('/deny on inbound row with unreachable remote returns 502 and leaves queue row pending', async () => {
seedInboundRequest('req-in-deny-fail');
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('connection refused'));
const response = await app.inject({
method: 'POST',
url: '/api/federation/approval-requests/req-in-deny-fail/deny',
});
expect(response.statusCode).toBe(502);
// Queue row still pending — admin can retry.
expect(testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, 'req-in-deny-fail')).get()).toBeDefined();
// No federation_peers row created because we couldn't deliver the denial.
expect(testDb.select().from(schema.federationPeers).all()).toHaveLength(0);
});
});
@@ -0,0 +1,525 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { eq } from 'drizzle-orm';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { setWorkerId } from '../utils/snowflake.js';
setWorkerId(1);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
// The userId set onto request by the mocked authenticate preHandler. Tests
// override this per-case to simulate different authenticated users.
let currentUserId = 'alice';
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
vi.mock('../config.js', () => ({
config: {
domain: 'local.example',
port: 3000,
host: '0.0.0.0',
jwtSecret: 'test-secret-12345678901234567890123456789012',
maxUploadSize: 100 * 1024 * 1024,
registrationOpen: true,
},
}));
vi.mock('../utils/auth.js', () => ({
authenticate: async (req: { userId?: string }) => {
req.userId = currentUserId;
},
requireAdmin: async () => {},
}));
vi.mock('../utils/federationAuth.js', async () => {
const actual = await vi.importActual<typeof import('../utils/federationAuth.js')>('../utils/federationAuth.js');
return {
...actual,
getOurOrigin: () => 'https://local.example',
generateHmacSecret: () => 'mock-generated-secret',
};
});
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToAdmins: vi.fn(),
getAllOnlineUserIds: () => [],
sendToUser: vi.fn(),
sendToDmMembers: vi.fn(),
},
}));
vi.mock('../utils/federationPeerActivation.js', () => ({
onPeerActivated: vi.fn(async () => undefined),
onPeerDeactivated: vi.fn(async () => undefined),
}));
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
const statements = sqlText.split(/-->\s*statement-breakpoint/);
for (const stmt of statements) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedInstanceSettings(): void {
testDb.insert(schema.instanceSettings).values({
id: 1,
instanceName: 'Local Backspace',
autoAcceptPeering: 0,
registrationOpen: 1,
updatedAt: Date.now(),
}).run();
}
function seedUser(id: string, username: string): void {
testDb.insert(schema.users).values({
id,
username,
passwordHash: 'x',
displayName: username,
createdAt: Date.now(),
}).run();
}
interface SeedNotif {
id: string;
userId: string;
kind: 'approved' | 'denied' | 'expired';
peerOrigin: string;
triggerReason: string;
triggerTarget: string;
createdAt: number;
readAt: number | null;
}
function seedNotification(n: SeedNotif): void {
testDb.insert(schema.peerApprovalNotifications).values({
id: n.id,
userId: n.userId,
kind: n.kind,
peerOrigin: n.peerOrigin,
triggerReason: n.triggerReason,
triggerTarget: n.triggerTarget,
createdAt: n.createdAt,
readAt: n.readAt,
}).run();
}
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
const { federationRoutes } = await import('./federation.js');
await app.register(federationRoutes);
await app.ready();
return app;
}
describe('GET /api/federation/peering-notifications', () => {
let app: FastifyInstance;
beforeEach(async () => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
seedInstanceSettings();
seedUser('alice', 'alice');
seedUser('bob', 'bob');
currentUserId = 'alice';
app = await buildApp();
});
afterEach(() => {
vi.restoreAllMocks();
sqlite.close();
});
// Test 22: GET → user's rows ordered DESC by createdAt.
it("returns the user's notifications ordered DESC by createdAt", async () => {
const t0 = 1_700_000_000_000;
seedNotification({
id: 'notif-old',
userId: 'alice',
kind: 'approved',
peerOrigin: 'https://orbit.example',
triggerReason: 'friend_add',
triggerTarget: 'someone@orbit.example',
createdAt: t0,
readAt: null,
});
seedNotification({
id: 'notif-new',
userId: 'alice',
kind: 'denied',
peerOrigin: 'https://other.example',
triggerReason: 'space_join',
triggerTarget: 'invite-xyz',
createdAt: t0 + 1_000,
readAt: null,
});
seedNotification({
id: 'notif-mid',
userId: 'alice',
kind: 'expired',
peerOrigin: 'https://third.example',
triggerReason: 'direct_message',
triggerTarget: 'pal@third.example',
createdAt: t0 + 500,
readAt: t0 + 800,
});
const response = await app.inject({
method: 'GET',
url: '/api/federation/peering-notifications',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
notifications: Array<{
id: string;
kind: string;
peerOrigin: string;
triggerReason: string;
triggerTarget: string;
createdAt: number;
readAt: number | null;
}>;
};
expect(body.notifications).toHaveLength(3);
expect(body.notifications.map(n => n.id)).toEqual(['notif-new', 'notif-mid', 'notif-old']);
// Shape check on the newest row.
expect(body.notifications[0]).toEqual({
id: 'notif-new',
kind: 'denied',
peerOrigin: 'https://other.example',
triggerReason: 'space_join',
triggerTarget: 'invite-xyz',
createdAt: t0 + 1_000,
readAt: null,
});
});
// Test 23: GET ?unread=1 → only readAt IS NULL rows.
it('filters to only unread notifications when ?unread=1', async () => {
const t0 = 1_700_000_000_000;
seedNotification({
id: 'notif-unread-1',
userId: 'alice',
kind: 'approved',
peerOrigin: 'https://a.example',
triggerReason: 'friend_add',
triggerTarget: 'a@a.example',
createdAt: t0,
readAt: null,
});
seedNotification({
id: 'notif-read',
userId: 'alice',
kind: 'denied',
peerOrigin: 'https://b.example',
triggerReason: 'space_join',
triggerTarget: 'invite-y',
createdAt: t0 + 100,
readAt: t0 + 200,
});
seedNotification({
id: 'notif-unread-2',
userId: 'alice',
kind: 'expired',
peerOrigin: 'https://c.example',
triggerReason: 'direct_message',
triggerTarget: 'c@c.example',
createdAt: t0 + 300,
readAt: null,
});
const response = await app.inject({
method: 'GET',
url: '/api/federation/peering-notifications?unread=1',
});
expect(response.statusCode).toBe(200);
const body = response.json() as { notifications: Array<{ id: string; readAt: number | null }> };
expect(body.notifications).toHaveLength(2);
// DESC ordering: unread-2 (newer) before unread-1 (older).
expect(body.notifications.map(n => n.id)).toEqual(['notif-unread-2', 'notif-unread-1']);
for (const n of body.notifications) {
expect(n.readAt).toBeNull();
}
});
// Test 26 (cross-user): GET shows only the requesting user's notifications.
it('does NOT return other users\' notifications', async () => {
seedNotification({
id: 'alice-notif',
userId: 'alice',
kind: 'approved',
peerOrigin: 'https://a.example',
triggerReason: 'friend_add',
triggerTarget: 'a@a.example',
createdAt: 1,
readAt: null,
});
seedNotification({
id: 'bob-notif',
userId: 'bob',
kind: 'denied',
peerOrigin: 'https://b.example',
triggerReason: 'friend_add',
triggerTarget: 'b@b.example',
createdAt: 2,
readAt: null,
});
const response = await app.inject({
method: 'GET',
url: '/api/federation/peering-notifications',
});
expect(response.statusCode).toBe(200);
const body = response.json() as { notifications: Array<{ id: string }> };
expect(body.notifications).toHaveLength(1);
// Alice's row comes back (isolation: bob-notif is excluded by userId filter
// in the WHERE clause; the response shape itself no longer surfaces userId).
expect(body.notifications[0]!.id).toBe('alice-notif');
});
});
describe('POST /api/federation/peering-notifications/:id/read', () => {
let app: FastifyInstance;
beforeEach(async () => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
seedInstanceSettings();
seedUser('alice', 'alice');
seedUser('bob', 'bob');
currentUserId = 'alice';
app = await buildApp();
});
afterEach(() => {
vi.restoreAllMocks();
sqlite.close();
});
// Test 24: POST :id/read → sets readAt.
it('marks the notification as read by setting readAt', async () => {
const t0 = 1_700_000_000_000;
seedNotification({
id: 'notif-1',
userId: 'alice',
kind: 'approved',
peerOrigin: 'https://a.example',
triggerReason: 'friend_add',
triggerTarget: 'a@a.example',
createdAt: t0,
readAt: null,
});
const before = Date.now();
const response = await app.inject({
method: 'POST',
url: '/api/federation/peering-notifications/notif-1/read',
});
const after = Date.now();
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
const row = testDb.select()
.from(schema.peerApprovalNotifications)
.where(eq(schema.peerApprovalNotifications.id, 'notif-1'))
.get();
expect(row).toBeDefined();
expect(row!.readAt).not.toBeNull();
expect(row!.readAt!).toBeGreaterThanOrEqual(before);
expect(row!.readAt!).toBeLessThanOrEqual(after);
});
// Test 26: POST :id/read on another user's notif → 403.
it("returns 403 when the notification belongs to another user; row UNTOUCHED", async () => {
seedNotification({
id: 'bob-notif',
userId: 'bob',
kind: 'denied',
peerOrigin: 'https://b.example',
triggerReason: 'friend_add',
triggerTarget: 'b@b.example',
createdAt: 1,
readAt: null,
});
// currentUserId is alice; row belongs to bob.
const response = await app.inject({
method: 'POST',
url: '/api/federation/peering-notifications/bob-notif/read',
});
expect(response.statusCode).toBe(403);
expect((response.json() as { error: string }).error).toBe('forbidden');
const row = testDb.select()
.from(schema.peerApprovalNotifications)
.where(eq(schema.peerApprovalNotifications.id, 'bob-notif'))
.get();
expect(row!.readAt).toBeNull();
});
// Test 26: POST :id/read on non-existent → 404.
it('returns 404 when the notification does not exist', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/federation/peering-notifications/does-not-exist/read',
});
expect(response.statusCode).toBe(404);
expect((response.json() as { error: string }).error).toBe('notification_not_found');
});
});
describe('POST /api/federation/peering-notifications/read-all', () => {
let app: FastifyInstance;
beforeEach(async () => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
seedInstanceSettings();
seedUser('alice', 'alice');
seedUser('bob', 'bob');
currentUserId = 'alice';
app = await buildApp();
});
afterEach(() => {
vi.restoreAllMocks();
sqlite.close();
});
// Test 25: POST read-all → marks all unread as read; returns count.
// Already-read rows are NOT touched (their readAt is preserved).
it('marks the user\'s unread rows as read; preserves already-read readAt; returns affected count', async () => {
const t0 = 1_700_000_000_000;
const ALREADY_READ_AT = t0 + 50;
seedNotification({
id: 'unread-1',
userId: 'alice',
kind: 'approved',
peerOrigin: 'https://a.example',
triggerReason: 'friend_add',
triggerTarget: 'a@a.example',
createdAt: t0,
readAt: null,
});
seedNotification({
id: 'unread-2',
userId: 'alice',
kind: 'denied',
peerOrigin: 'https://b.example',
triggerReason: 'space_join',
triggerTarget: 'invite-y',
createdAt: t0 + 100,
readAt: null,
});
seedNotification({
id: 'already-read',
userId: 'alice',
kind: 'expired',
peerOrigin: 'https://c.example',
triggerReason: 'direct_message',
triggerTarget: 'c@c.example',
createdAt: t0 + 200,
readAt: ALREADY_READ_AT,
});
const before = Date.now();
const response = await app.inject({
method: 'POST',
url: '/api/federation/peering-notifications/read-all',
});
const after = Date.now();
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, count: 2 });
const u1 = testDb.select().from(schema.peerApprovalNotifications)
.where(eq(schema.peerApprovalNotifications.id, 'unread-1')).get();
const u2 = testDb.select().from(schema.peerApprovalNotifications)
.where(eq(schema.peerApprovalNotifications.id, 'unread-2')).get();
const ar = testDb.select().from(schema.peerApprovalNotifications)
.where(eq(schema.peerApprovalNotifications.id, 'already-read')).get();
expect(u1!.readAt).not.toBeNull();
expect(u1!.readAt!).toBeGreaterThanOrEqual(before);
expect(u1!.readAt!).toBeLessThanOrEqual(after);
expect(u2!.readAt).not.toBeNull();
expect(u2!.readAt!).toBeGreaterThanOrEqual(before);
expect(u2!.readAt!).toBeLessThanOrEqual(after);
// already-read row's readAt is preserved (unchanged).
expect(ar!.readAt).toBe(ALREADY_READ_AT);
});
// Test 26: POST read-all only marks the requesting user's rows.
it('does NOT touch other users\' notifications', async () => {
seedNotification({
id: 'alice-unread',
userId: 'alice',
kind: 'approved',
peerOrigin: 'https://a.example',
triggerReason: 'friend_add',
triggerTarget: 'a@a.example',
createdAt: 1,
readAt: null,
});
seedNotification({
id: 'bob-unread',
userId: 'bob',
kind: 'denied',
peerOrigin: 'https://b.example',
triggerReason: 'friend_add',
triggerTarget: 'b@b.example',
createdAt: 2,
readAt: null,
});
const response = await app.inject({
method: 'POST',
url: '/api/federation/peering-notifications/read-all',
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, count: 1 });
const aliceRow = testDb.select().from(schema.peerApprovalNotifications)
.where(eq(schema.peerApprovalNotifications.id, 'alice-unread')).get();
const bobRow = testDb.select().from(schema.peerApprovalNotifications)
.where(eq(schema.peerApprovalNotifications.id, 'bob-unread')).get();
expect(aliceRow!.readAt).not.toBeNull();
expect(bobRow!.readAt).toBeNull();
});
});
@@ -0,0 +1,433 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { eq } from 'drizzle-orm';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { setWorkerId } from '../utils/snowflake.js';
setWorkerId(1);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
// The userId set onto request by the mocked authenticate preHandler. Tests
// override this per-case to simulate different authenticated users.
let currentUserId = 'alice';
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
vi.mock('../config.js', () => ({
config: {
domain: 'local.example',
port: 3000,
host: '0.0.0.0',
jwtSecret: 'test-secret-12345678901234567890123456789012',
maxUploadSize: 100 * 1024 * 1024,
registrationOpen: true,
},
}));
vi.mock('../utils/auth.js', () => ({
authenticate: async (req: { userId?: string }) => {
req.userId = currentUserId;
},
requireAdmin: async () => {},
}));
vi.mock('../utils/federationAuth.js', async () => {
const actual = await vi.importActual<typeof import('../utils/federationAuth.js')>('../utils/federationAuth.js');
return {
...actual,
getOurOrigin: () => 'https://local.example',
generateHmacSecret: () => 'mock-generated-secret',
};
});
const sentToUser = vi.fn();
const sentToAdmins = vi.fn();
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToAdmins: sentToAdmins,
getAllOnlineUserIds: () => [],
sendToUser: sentToUser,
sendToDmMembers: vi.fn(),
},
}));
vi.mock('../utils/federationPeerActivation.js', () => ({
onPeerActivated: vi.fn(async () => undefined),
onPeerDeactivated: vi.fn(async () => undefined),
}));
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
const statements = sqlText.split(/-->\s*statement-breakpoint/);
for (const stmt of statements) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedInstanceSettings(): void {
testDb.insert(schema.instanceSettings).values({
id: 1,
instanceName: 'Local Backspace',
autoAcceptPeering: 0,
registrationOpen: 1,
updatedAt: Date.now(),
}).run();
}
function seedUser(id: string, username: string): void {
testDb.insert(schema.users).values({
id,
username,
passwordHash: 'x',
displayName: username,
createdAt: Date.now(),
}).run();
}
interface SeedSub {
id: string;
userId: string;
reason: 'friend_add' | 'space_join' | 'direct_message';
target: string;
createdAt: number;
}
function seedOutboundRequest(opts: {
id: string;
origin: string;
instanceName?: string | null;
subscribers: SeedSub[];
}): void {
const now = Date.now();
testDb.insert(schema.peerApprovalRequests).values({
id: opts.id,
origin: opts.origin,
direction: 'outbound',
instanceName: opts.instanceName ?? null,
hmacSecret: null,
requestedAt: now,
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
approvalToken: null,
}).run();
for (const sub of opts.subscribers) {
testDb.insert(schema.peerApprovalSubscribers).values({
id: sub.id,
requestId: opts.id,
userId: sub.userId,
triggerReason: sub.reason,
triggerTarget: sub.target,
createdAt: sub.createdAt,
}).run();
}
}
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
const { federationRoutes } = await import('./federation.js');
await app.register(federationRoutes);
await app.ready();
return app;
}
describe('GET /api/federation/peering-subscriptions', () => {
let app: FastifyInstance;
beforeEach(async () => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
seedInstanceSettings();
seedUser('alice', 'alice');
seedUser('bob', 'bob');
sentToUser.mockClear();
sentToAdmins.mockClear();
currentUserId = 'alice';
app = await buildApp();
});
afterEach(() => {
vi.restoreAllMocks();
sqlite.close();
});
// Test 18: GET own subscriptions → returns user rows joined to parent,
// ordered DESC by createdAt, scoped to the requesting user only.
it("returns the user's subscriber rows joined to parent requests, ordered DESC by createdAt, scoped to the user", async () => {
const t0 = 1_700_000_000_000;
seedOutboundRequest({
id: 'req-A',
origin: 'https://orbit.example',
instanceName: 'Orbit',
subscribers: [
{ id: 'sub-A-alice-old', userId: 'alice', reason: 'friend_add', target: 'someone@orbit.example', createdAt: t0 },
{ id: 'sub-A-bob', userId: 'bob', reason: 'space_join', target: 'invite-xyz', createdAt: t0 + 100 },
],
});
seedOutboundRequest({
id: 'req-B',
origin: 'https://other.example',
instanceName: null,
subscribers: [
{ id: 'sub-B-alice-new', userId: 'alice', reason: 'direct_message', target: 'pal@other.example', createdAt: t0 + 1_000 },
],
});
const response = await app.inject({
method: 'GET',
url: '/api/federation/peering-subscriptions',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
subscriptions: Array<{
id: string;
requestId: string;
peerOrigin: string;
peerInstanceName: string | null;
triggerReason: string;
triggerTarget: string;
createdAt: number;
}>;
};
// Only alice's two subscriptions returned (bob's not included).
expect(body.subscriptions).toHaveLength(2);
expect(body.subscriptions.map(s => s.id)).toEqual(['sub-B-alice-new', 'sub-A-alice-old']);
// DESC-by-createdAt ordering (newest first).
const newest = body.subscriptions[0]!;
const older = body.subscriptions[1]!;
expect(newest.createdAt).toBe(t0 + 1_000);
expect(older.createdAt).toBe(t0);
// Shape: every documented field is present and joined correctly.
expect(newest).toEqual({
id: 'sub-B-alice-new',
requestId: 'req-B',
peerOrigin: 'https://other.example',
peerInstanceName: null,
triggerReason: 'direct_message',
triggerTarget: 'pal@other.example',
createdAt: t0 + 1_000,
});
expect(older).toEqual({
id: 'sub-A-alice-old',
requestId: 'req-A',
peerOrigin: 'https://orbit.example',
peerInstanceName: 'Orbit',
triggerReason: 'friend_add',
triggerTarget: 'someone@orbit.example',
createdAt: t0,
});
});
it('returns an empty array when the user has no subscriptions', async () => {
seedOutboundRequest({
id: 'req-only-bob',
origin: 'https://x.example',
subscribers: [
{ id: 'sub-only-bob', userId: 'bob', reason: 'friend_add', target: 'b@x.example', createdAt: Date.now() },
],
});
const response = await app.inject({
method: 'GET',
url: '/api/federation/peering-subscriptions',
});
expect(response.statusCode).toBe(200);
const body = response.json() as { subscriptions: unknown[] };
expect(body.subscriptions).toEqual([]);
});
});
describe('DELETE /api/federation/peering-subscriptions/:id', () => {
let app: FastifyInstance;
beforeEach(async () => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
seedInstanceSettings();
seedUser('alice', 'alice');
seedUser('bob', 'bob');
sentToUser.mockClear();
sentToAdmins.mockClear();
currentUserId = 'alice';
app = await buildApp();
});
afterEach(() => {
vi.restoreAllMocks();
sqlite.close();
});
// Test 19a: cascade-on-last — alice cancels the SOLE subscriber → parent
// is cascade-deleted; admin gets federation_peers_changed; user gets
// peering_subscription_changed.
it('cascade-deletes parent when the canceller was the last subscriber; fires both WS events', async () => {
seedOutboundRequest({
id: 'req-solo',
origin: 'https://solo.example',
subscribers: [
{ id: 'sub-solo-alice', userId: 'alice', reason: 'friend_add', target: 'x@solo.example', createdAt: Date.now() },
],
});
const response = await app.inject({
method: 'DELETE',
url: '/api/federation/peering-subscriptions/sub-solo-alice',
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
// Subscriber row removed.
expect(testDb.select().from(schema.peerApprovalSubscribers)
.where(eq(schema.peerApprovalSubscribers.id, 'sub-solo-alice')).get()).toBeUndefined();
// Parent cascade-deleted.
expect(testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, 'req-solo')).get()).toBeUndefined();
// Admin queue refresh broadcast.
const adminEvents = sentToAdmins.mock.calls.filter(c => {
const ev = c[0] as { type?: string };
return ev?.type === 'federation_peers_changed';
});
expect(adminEvents).toHaveLength(1);
// User-facing list refresh broadcast.
const userEvents = sentToUser.mock.calls.filter(c => {
const uid = c[0] as string;
const ev = c[1] as { type?: string };
return uid === 'alice' && ev?.type === 'peering_subscription_changed';
});
expect(userEvents).toHaveLength(1);
// No notification written for the canceller.
expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0);
});
// Test 19b: no-cascade-when-others-remain — alice cancels her row but bob
// still subscribes → parent stays; admin event NOT fired; user event fired.
it('does NOT cascade-delete parent when other subscribers remain; only fires user WS event', async () => {
seedOutboundRequest({
id: 'req-shared',
origin: 'https://shared.example',
instanceName: 'Shared',
subscribers: [
{ id: 'sub-shared-alice', userId: 'alice', reason: 'friend_add', target: 'a@shared.example', createdAt: Date.now() },
{ id: 'sub-shared-bob', userId: 'bob', reason: 'space_join', target: 'invite-yz', createdAt: Date.now() },
],
});
const response = await app.inject({
method: 'DELETE',
url: '/api/federation/peering-subscriptions/sub-shared-alice',
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
// Alice's row removed.
expect(testDb.select().from(schema.peerApprovalSubscribers)
.where(eq(schema.peerApprovalSubscribers.id, 'sub-shared-alice')).get()).toBeUndefined();
// Bob's row still present.
expect(testDb.select().from(schema.peerApprovalSubscribers)
.where(eq(schema.peerApprovalSubscribers.id, 'sub-shared-bob')).get()).toBeDefined();
// Parent NOT deleted.
expect(testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, 'req-shared')).get()).toBeDefined();
// Admin event NOT fired (queue did not change).
const adminEvents = sentToAdmins.mock.calls.filter(c => {
const ev = c[0] as { type?: string };
return ev?.type === 'federation_peers_changed';
});
expect(adminEvents).toHaveLength(0);
// User event still fired.
const userEvents = sentToUser.mock.calls.filter(c => {
const uid = c[0] as string;
const ev = c[1] as { type?: string };
return uid === 'alice' && ev?.type === 'peering_subscription_changed';
});
expect(userEvents).toHaveLength(1);
// No notification written.
expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0);
});
// Test 20: foreign-user delete → 403, no DB mutation, no broadcasts.
it('returns 403 when the row belongs to another user; no DB mutation; no WS broadcasts', async () => {
seedOutboundRequest({
id: 'req-foreign',
origin: 'https://foreign.example',
subscribers: [
{ id: 'sub-foreign-bob', userId: 'bob', reason: 'friend_add', target: 'b@foreign.example', createdAt: Date.now() },
],
});
// currentUserId is alice, but the row is bob's.
const response = await app.inject({
method: 'DELETE',
url: '/api/federation/peering-subscriptions/sub-foreign-bob',
});
expect(response.statusCode).toBe(403);
const body = response.json() as { error: string };
expect(body.error).toBe('forbidden');
// Row UNTOUCHED.
expect(testDb.select().from(schema.peerApprovalSubscribers)
.where(eq(schema.peerApprovalSubscribers.id, 'sub-foreign-bob')).get()).toBeDefined();
// Parent UNTOUCHED.
expect(testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, 'req-foreign')).get()).toBeDefined();
// No broadcasts.
expect(sentToAdmins).not.toHaveBeenCalled();
expect(sentToUser).not.toHaveBeenCalled();
});
// Test 21: nonexistent row → 404.
it('returns 404 when the subscription id does not exist', async () => {
const response = await app.inject({
method: 'DELETE',
url: '/api/federation/peering-subscriptions/sub-does-not-exist',
});
expect(response.statusCode).toBe(404);
const body = response.json() as { error: string };
expect(body.error).toBe('subscription_not_found');
// No broadcasts.
expect(sentToAdmins).not.toHaveBeenCalled();
expect(sentToUser).not.toHaveBeenCalled();
});
});
+721 -202
View File
@@ -20,7 +20,7 @@ import { tombstoneUser, collectDeletionBroadcastTargets, collectProfileBroadcast
import { computeFederatedId, getDmParticipants, sendCallRelay } from '../utils/federationOutbox.js'; import { computeFederatedId, getDmParticipants, sendCallRelay } from '../utils/federationOutbox.js';
import { onPeerActivated, onPeerDeactivated } from '../utils/federationPeerActivation.js'; import { onPeerActivated, onPeerDeactivated } from '../utils/federationPeerActivation.js';
import { getDmMessageWithUser } from './dm.js'; import { getDmMessageWithUser } from './dm.js';
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, DmChannel, FederationRelayProfileSnapshot, FederationIdentityDeleteS2SRequest, FederationProfileUpdatePayload, ServerEvent } from '@backspace/shared'; import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, DmChannel, FederationRelayProfileSnapshot, FederationIdentityDeleteS2SRequest, FederationProfileUpdatePayload, ServerEvent, ApprovalRequestSubscriberSummary, PeeringTriggerReason } from '@backspace/shared';
/** Fields safe to expose to admin callers (everything except hmacSecret). */ /** Fields safe to expose to admin callers (everything except hmacSecret). */
interface SanitizedPeer { interface SanitizedPeer {
@@ -317,6 +317,487 @@ function queueApprovalRequest(
}); });
} }
/**
* Inbound approve — admin accepts a remote instance's peering request.
* Generates fresh HMAC, sends `/peer/accept` to the remote, and on success
* activates the peer locally. Preserves the historical behavior verbatim;
* extracted from the route handler so the dispatcher can branch on direction.
*/
async function handleInboundApprove(
approvalReq: typeof schema.peerApprovalRequests.$inferSelect,
localOrigin: string,
reply: FastifyReply,
): Promise<FastifyReply> {
const db = getDb();
const id = approvalReq.id;
const existingPeer = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, approvalReq.origin))
.get();
if (existingPeer && existingPeer.status === 'active') {
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, id))
.run();
return reply.code(200).send({ success: true, peer: sanitizePeer(existingPeer) });
}
if (existingPeer) {
db.delete(schema.federationPeers)
.where(eq(schema.federationPeers.id, existingPeer.id))
.run();
}
const hmacSecret = generateHmacSecret();
const peerId = generateSnowflake();
const now = Date.now();
db.insert(schema.federationPeers).values({
id: peerId,
origin: approvalReq.origin,
instanceName: approvalReq.instanceName,
hmacSecret,
status: 'pending',
createdAt: now,
}).run();
try {
const instanceName = db
.select({ name: schema.instanceSettings.instanceName })
.from(schema.instanceSettings)
.where(eq(schema.instanceSettings.id, 1))
.get()?.name ?? undefined;
const response = await fetch(`${approvalReq.origin}/api/federation/peer/accept`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceOrigin: localOrigin,
hmacSecret,
instanceName,
// Forward the stored token (issued in our 202 response when the
// remote first sent /peer/accept). Lets the remote verify mutual
// admin approval. Spec §3.7.
...(approvalReq.approvalToken ? { approvalToken: approvalReq.approvalToken } : {}),
}),
signal: AbortSignal.timeout(10_000),
});
if (response.status === 202) {
// Remote instance also has autoAcceptPeering off — they queued our request.
// Don't activate our peer. Set to awaiting_approval until their admin also approves.
// Capture the approval token they returned so the next inbound
// /peer/accept (when their admin approves) can be verified. §3.7.
let returnedToken: string | null = null;
try {
const body = (await response.json()) as { approvalToken?: string };
if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) {
returnedToken = body.approvalToken;
}
} catch {
// Non-JSON / empty body — legacy peer.
}
db.update(schema.federationPeers)
.set({ status: 'awaiting_approval', approvalToken: returnedToken })
.where(eq(schema.federationPeers.id, peerId))
.run();
// Delete the approval request since we already acted on it
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, id))
.run();
return reply.code(200).send({
success: true,
awaitingRemoteApproval: true,
message: 'Remote instance also requires admin approval. Your request has been queued on their side.',
});
}
if (!response.ok) {
let errorMessage = `Remote instance rejected handshake (HTTP ${response.status})`;
try {
const body = await response.json() as { error?: string };
if (body.error) errorMessage = body.error;
} catch { /* ignore */ }
db.delete(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.run();
return reply.code(502).send({ error: errorMessage, statusCode: 502 });
}
// Parse the remote's instanceName from the response body so the
// federation panel renders a friendly label. Tolerate omission and
// non-JSON bodies — same pattern as performHandshake and /peer/initiate.
let remoteInstanceName: string | null = null;
try {
const body = (await response.json()) as { instanceName?: string | null };
if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) {
remoteInstanceName = body.instanceName;
}
} catch {
// Non-JSON body — leave null.
}
db.update(schema.federationPeers)
.set({ status: 'active', lastSeenAt: now, instanceName: remoteInstanceName, approvalToken: null })
.where(eq(schema.federationPeers.id, peerId))
.run();
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, id))
.run();
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
onPeerActivated(peerId, 'approval_handshake').catch(err =>
console.error('[federation] onPeerActivated from /approval-requests/:id/approve failed:', err)
);
const peer = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.get();
return reply.code(200).send({ success: true, peer: peer ? sanitizePeer(peer) : undefined });
} catch (err: unknown) {
db.delete(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.run();
const message = err instanceof Error ? err.message : 'Unknown error';
if (err instanceof DOMException && err.name === 'TimeoutError') {
return reply.code(504).send({
error: 'Remote instance did not respond within 10 seconds',
statusCode: 504,
});
}
return reply.code(502).send({
error: `Failed to reach remote instance: ${message}`,
statusCode: 502,
});
}
}
/**
* Outbound approve — admin authorizes the local instance to peer with a
* remote that one or more of its users have requested. Generates fresh HMAC,
* sends `/peer/accept` to the remote, and:
* - 200 → activate peer; `onPeerActivated` runs and (per Task 6) fans out
* approved-notifications to outbound subscribers and cascade-deletes the
* queue row. The handler MUST NOT duplicate that cleanup.
* - 202 → remote also gates; transition to `awaiting_approval`, capture
* the returned token, leave the queue row + subscribers untouched (they
* wait for the remote admin to approve and the eventual full activation
* to fan out via `onPeerActivated`).
* - 4xx/5xx/network → clean up the peer row we created; leave the queue
* row alone so the admin can retry.
*/
async function handleOutboundApprove(
approvalReq: typeof schema.peerApprovalRequests.$inferSelect,
localOrigin: string,
reply: FastifyReply,
): Promise<FastifyReply> {
const db = getDb();
const hmacSecret = generateHmacSecret();
const peerId = generateSnowflake();
const now = Date.now();
// Insert the peer row in 'pending' so failure paths roll back cleanly.
db.insert(schema.federationPeers).values({
id: peerId,
origin: approvalReq.origin,
instanceName: approvalReq.instanceName,
hmacSecret,
status: 'pending',
createdAt: now,
}).run();
const instanceName = db
.select({ name: schema.instanceSettings.instanceName })
.from(schema.instanceSettings)
.where(eq(schema.instanceSettings.id, 1))
.get()?.name ?? undefined;
let response: Response;
try {
response = await fetch(`${approvalReq.origin}/api/federation/peer/accept`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceOrigin: localOrigin,
hmacSecret,
instanceName,
// No approvalToken — outbound rows are admin-initiated locally; we
// hold no prior token from the remote and rely on the remote's own
// autoAcceptPeering setting to decide 200 vs 202.
}),
signal: AbortSignal.timeout(10_000),
});
} catch (err: unknown) {
db.delete(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.run();
const message = err instanceof Error ? err.message : 'Unknown error';
if (err instanceof DOMException && err.name === 'TimeoutError') {
return reply.code(504).send({
error: 'Remote instance did not respond within 10 seconds',
statusCode: 504,
});
}
return reply.code(503).send({
error: `Remote instance unreachable: ${message}`,
statusCode: 503,
});
}
if (response.status === 202) {
// Remote also gates new peers. Capture the approval token they returned
// so the next inbound /peer/accept (when the remote admin approves) can
// verify mutual admin approval. The outbound queue row and its
// subscribers REMAIN — `onPeerActivated` is NOT called here; subscribers
// wait for the eventual activation (fanout happens then).
let returnedToken: string | null = null;
try {
const body = (await response.json()) as { approvalToken?: string };
if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) {
returnedToken = body.approvalToken;
}
} catch {
// Non-JSON / empty body — legacy peer with no token to capture.
}
db.update(schema.federationPeers)
.set({ status: 'awaiting_approval', approvalToken: returnedToken })
.where(eq(schema.federationPeers.id, peerId))
.run();
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
const peer = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.get();
return reply.code(200).send({
success: true,
peerStatus: 'awaiting_approval' as const,
awaitingRemoteApproval: true,
message: 'Remote instance also requires admin approval. Your request has been queued on their side.',
peer: peer ? sanitizePeer(peer) : undefined,
});
}
if (!response.ok) {
let errorMessage = `Remote instance rejected handshake (HTTP ${response.status})`;
try {
const body = await response.json() as { error?: string };
if (body.error) errorMessage = body.error;
} catch { /* ignore */ }
// Clean up the peer row we created. Leave the outbound queue row alone
// so the admin can retry without re-collecting subscribers.
db.delete(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.run();
return reply.code(502).send({
error: errorMessage,
statusCode: 502,
remoteStatus: response.status,
});
}
// 200 — peer activated. Capture remote's instanceName for the friendly label.
let remoteInstanceName: string | null = approvalReq.instanceName;
try {
const body = (await response.json()) as { instanceName?: string | null };
if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) {
remoteInstanceName = body.instanceName;
}
} catch {
// Non-JSON body — keep approvalReq.instanceName (may be null).
}
db.update(schema.federationPeers)
.set({
status: 'active',
lastSeenAt: now,
instanceName: remoteInstanceName,
approvalToken: null,
})
.where(eq(schema.federationPeers.id, peerId))
.run();
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
// onPeerActivated runs fanoutOutboundSubscribers (Task 6) which:
// - inserts kind='approved' notifications for each subscriber,
// - sends `peering_notification_received` WS to each subscriber,
// - cascade-deletes the parent + subscriber rows.
// Do NOT duplicate any of that here — it would double-notify and corrupt
// the queue.
onPeerActivated(peerId, 'approval_handshake').catch(err =>
console.error('[federation] onPeerActivated from outbound /approval-requests/:id/approve failed:', err)
);
const peer = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.get();
return reply.code(200).send({
success: true,
peerStatus: 'active' as const,
peer: peer ? sanitizePeer(peer) : undefined,
});
}
/**
* Inbound deny — admin rejects a remote instance's peering request. Fires
* the existing /peer/denied notification to the remote, marks any local
* peer row as `rejected`, and clears the queue row. Preserves historical
* behavior verbatim.
*/
async function handleInboundDeny(
approvalReq: typeof schema.peerApprovalRequests.$inferSelect,
reply: FastifyReply,
): Promise<FastifyReply> {
const db = getDb();
const id = approvalReq.id;
// Inbound rows always carry hmacSecret (CHECK constraint enforces this).
// If it's somehow null, we cannot sign /peer/denied — surface clearly.
if (!approvalReq.hmacSecret) {
return reply.code(500).send({
error: 'Inbound approval request is missing hmacSecret — cannot deliver /peer/denied notification.',
statusCode: 500,
});
}
const ourOrigin = getOurOrigin();
const denialBody = JSON.stringify({
origin: ourOrigin,
reason: 'denied_by_admin' as const,
message: 'Request denied by admin',
});
const headers = buildFederationHeaders(denialBody, approvalReq.hmacSecret, ourOrigin);
let notificationSent = false;
try {
const response = await fetch(`${approvalReq.origin}/api/federation/peer/denied`, {
method: 'POST',
headers,
body: denialBody,
signal: AbortSignal.timeout(10_000),
});
notificationSent = response.ok;
} catch {
// Network error
}
if (!notificationSent) {
return reply.code(502).send({
error: 'Denial notification could not be delivered to the remote instance. The request is still pending — you can retry or wait for it to expire.',
statusCode: 502,
});
}
const existingPeer = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, approvalReq.origin))
.get();
if (!existingPeer) {
db.insert(schema.federationPeers).values({
id: generateSnowflake(),
origin: approvalReq.origin,
instanceName: approvalReq.instanceName,
hmacSecret: approvalReq.hmacSecret,
status: 'rejected',
createdAt: Date.now(),
}).run();
} else if (existingPeer.status !== 'active') {
db.update(schema.federationPeers)
.set({ status: 'rejected' })
.where(eq(schema.federationPeers.id, existingPeer.id))
.run();
}
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, id))
.run();
return reply.code(200).send({ success: true });
}
/**
* Outbound deny — admin refuses local users' peering request. Fans out
* `kind='denied'` notifications to each subscriber and cascade-deletes the
* parent (which clears subscribers via FK cascade). No remote network call
* — outbound rows have no /peer/denied counterpart on the wire (the remote
* never knew we were considering this).
*/
async function handleOutboundDeny(
approvalReq: typeof schema.peerApprovalRequests.$inferSelect,
reply: FastifyReply,
): Promise<FastifyReply> {
const db = getDb();
const subscribers = db
.select()
.from(schema.peerApprovalSubscribers)
.where(eq(schema.peerApprovalSubscribers.requestId, approvalReq.id))
.all();
const now = Date.now();
for (const sub of subscribers) {
db.insert(schema.peerApprovalNotifications)
.values({
id: generateSnowflake(),
userId: sub.userId,
kind: 'denied',
peerOrigin: approvalReq.origin,
triggerReason: sub.triggerReason,
triggerTarget: sub.triggerTarget,
createdAt: now,
readAt: null,
})
.run();
connectionManager.sendToUser(sub.userId, {
type: 'peering_notification_received' as const,
kind: 'denied',
});
// Subscriber row is about to cascade-delete; refresh the user's pending list.
connectionManager.sendToUser(sub.userId, {
type: 'peering_subscription_changed' as const,
});
}
// Cascade-delete clears subscribers via onDelete: 'cascade'.
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, approvalReq.id))
.run();
// Tell admins the queue changed.
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
if (subscribers.length > 0) {
console.log(
`[federation] handleOutboundDeny denied ${subscribers.length} subscriber notification${subscribers.length === 1 ? '' : 's'} for ${approvalReq.origin}`,
);
}
return reply.code(200).send({ success: true });
}
export async function federationRoutes(app: FastifyInstance): Promise<void> { export async function federationRoutes(app: FastifyInstance): Promise<void> {
// ─── POST /api/federation/peer/initiate ──────────────────────────────────── // ─── POST /api/federation/peer/initiate ────────────────────────────────────
// Admin-only: start a peering handshake with a remote instance. // Admin-only: start a peering handshake with a remote instance.
@@ -798,14 +1279,26 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
} }
const { ensurePeered } = await import('../utils/federationPeering.js'); const { ensurePeered } = await import('../utils/federationPeering.js');
const result = await ensurePeered(remoteOrigin); // NOTE: /peer/ensure is currently only invoked from friend-add client paths
// (see packages/web/src/stores/instanceStore.ts ensurePeered references).
// The hardcoded reason here is correct TODAY but will become wrong when
// DM-to-stranger or space-join grow into the gate. When that happens,
// surface the reason and target through the request body instead. Do NOT
// silently leave the hardcoding in place when adding a new caller.
const result = await ensurePeered(remoteOrigin, {
kind: 'user_action',
userId: request.userId,
reason: 'friend_add',
target: remoteOrigin,
});
// NOTE: The internal EnsurePeeredResult status names differ from the client-facing // NOTE: The internal EnsurePeeredResult status names differ from the client-facing
// peeringStatus values. The mapping: // peeringStatus values. The mapping:
// 'active' → 'active' (peer is live) // 'active' → 'active' (peer is live)
// 'rejected' → 'rejected' (permanently blocked) // 'rejected' → 'rejected' (permanently blocked)
// 'pending' → 'awaiting_approval' (queued on remote, waiting for admin) // 'pending' → 'awaiting_approval' (queued on remote, waiting for admin)
// 'failed' → 'pending' (transient error, will retry automatically) // 'failed' → 'pending' (transient error, will retry automatically)
// 'admin_required' → 'admin_required' (local outbound gate fired — our admin must approve)
// The internal 'pending' means "we got a 202 from the remote — admin hasn't acted yet", // The internal 'pending' means "we got a 202 from the remote — admin hasn't acted yet",
// while 'failed' means "network/timeout — the outbox worker will retry next tick". // while 'failed' means "network/timeout — the outbox worker will retry next tick".
// The client sees 'awaiting_approval' (actionable info) vs 'pending' (transient, will resolve). // The client sees 'awaiting_approval' (actionable info) vs 'pending' (transient, will resolve).
@@ -818,6 +1311,8 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send({ peeringStatus: 'awaiting_approval', error: result.error }); return reply.code(200).send({ peeringStatus: 'awaiting_approval', error: result.error });
case 'failed': case 'failed':
return reply.code(200).send({ peeringStatus: 'pending', error: result.error }); return reply.code(200).send({ peeringStatus: 'pending', error: result.error });
case 'admin_required':
return reply.code(200).send({ peeringStatus: 'admin_required' });
default: default:
return reply.code(200).send({ peeringStatus: 'pending', error: 'Unknown peering result' }); return reply.code(200).send({ peeringStatus: 'pending', error: 'Unknown peering result' });
} }
@@ -1161,6 +1656,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
.select({ .select({
id: schema.peerApprovalRequests.id, id: schema.peerApprovalRequests.id,
origin: schema.peerApprovalRequests.origin, origin: schema.peerApprovalRequests.origin,
direction: schema.peerApprovalRequests.direction,
instanceName: schema.peerApprovalRequests.instanceName, instanceName: schema.peerApprovalRequests.instanceName,
requestedAt: schema.peerApprovalRequests.requestedAt, requestedAt: schema.peerApprovalRequests.requestedAt,
expiresAt: schema.peerApprovalRequests.expiresAt, expiresAt: schema.peerApprovalRequests.expiresAt,
@@ -1169,11 +1665,50 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
.orderBy(desc(schema.peerApprovalRequests.requestedAt)) .orderBy(desc(schema.peerApprovalRequests.requestedAt))
.all(); .all();
return reply.code(200).send({ requests }); // For outbound rows, fetch subscriber summaries (joined with users for username).
// Inbound rows have no subscriber concept; field is omitted in their response.
const outboundIds = requests.filter(r => r.direction === 'outbound').map(r => r.id);
const subscribersByRequestId = new Map<string, ApprovalRequestSubscriberSummary[]>();
if (outboundIds.length > 0) {
const rows = db
.select({
requestId: schema.peerApprovalSubscribers.requestId,
userId: schema.peerApprovalSubscribers.userId,
username: schema.users.username,
triggerReason: schema.peerApprovalSubscribers.triggerReason,
triggerTarget: schema.peerApprovalSubscribers.triggerTarget,
})
.from(schema.peerApprovalSubscribers)
.innerJoin(schema.users, eq(schema.users.id, schema.peerApprovalSubscribers.userId))
.where(inArray(schema.peerApprovalSubscribers.requestId, outboundIds))
.all();
for (const row of rows) {
const arr = subscribersByRequestId.get(row.requestId) ?? [];
arr.push({
userId: row.userId,
username: row.username,
triggerReason: row.triggerReason as PeeringTriggerReason,
triggerTarget: row.triggerTarget,
});
subscribersByRequestId.set(row.requestId, arr);
}
}
return reply.code(200).send({
requests: requests.map(r =>
r.direction === 'outbound'
? { ...r, subscribers: subscribersByRequestId.get(r.id) ?? [] }
: r,
),
});
}, },
); );
// ─── POST /api/federation/approval-requests/:id/approve ─────────────────── // ─── POST /api/federation/approval-requests/:id/approve ───────────────────
// Direction-branched: inbound rows complete the existing accept-handshake
// path (preserved verbatim); outbound rows initiate /peer/accept against
// the remote, capturing 200/202 outcomes and leaving the queue intact on
// failure so the admin can retry.
app.post<{ Params: { id: string } }>( app.post<{ Params: { id: string } }>(
'/api/federation/approval-requests/:id/approve', '/api/federation/approval-requests/:id/approve',
{ preHandler: [authenticate, requireAdmin] }, { preHandler: [authenticate, requireAdmin] },
@@ -1201,158 +1736,18 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
}); });
} }
const existingPeer = db if (approvalReq.direction === 'outbound') {
.select() return await handleOutboundApprove(approvalReq, localOrigin, reply);
.from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, approvalReq.origin))
.get();
if (existingPeer && existingPeer.status === 'active') {
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, id))
.run();
return reply.code(200).send({ success: true, peer: sanitizePeer(existingPeer) });
} }
if (existingPeer) { return await handleInboundApprove(approvalReq, localOrigin, reply);
db.delete(schema.federationPeers)
.where(eq(schema.federationPeers.id, existingPeer.id))
.run();
}
const hmacSecret = generateHmacSecret();
const peerId = generateSnowflake();
const now = Date.now();
db.insert(schema.federationPeers).values({
id: peerId,
origin: approvalReq.origin,
instanceName: approvalReq.instanceName,
hmacSecret,
status: 'pending',
createdAt: now,
}).run();
try {
const instanceName = db
.select({ name: schema.instanceSettings.instanceName })
.from(schema.instanceSettings)
.where(eq(schema.instanceSettings.id, 1))
.get()?.name ?? undefined;
const response = await fetch(`${approvalReq.origin}/api/federation/peer/accept`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceOrigin: localOrigin,
hmacSecret,
instanceName,
// Forward the stored token (issued in our 202 response when the
// remote first sent /peer/accept). Lets the remote verify mutual
// admin approval. Spec §3.7.
...(approvalReq.approvalToken ? { approvalToken: approvalReq.approvalToken } : {}),
}),
signal: AbortSignal.timeout(10_000),
});
if (response.status === 202) {
// Remote instance also has autoAcceptPeering off — they queued our request.
// Don't activate our peer. Set to awaiting_approval until their admin also approves.
// Capture the approval token they returned so the next inbound
// /peer/accept (when their admin approves) can be verified. §3.7.
let returnedToken: string | null = null;
try {
const body = (await response.json()) as { approvalToken?: string };
if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) {
returnedToken = body.approvalToken;
}
} catch {
// Non-JSON / empty body — legacy peer.
}
db.update(schema.federationPeers)
.set({ status: 'awaiting_approval', approvalToken: returnedToken })
.where(eq(schema.federationPeers.id, peerId))
.run();
// Delete the approval request since we already acted on it
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, id))
.run();
return reply.code(200).send({
success: true,
awaitingRemoteApproval: true,
message: 'Remote instance also requires admin approval. Your request has been queued on their side.',
});
}
if (!response.ok) {
let errorMessage = `Remote instance rejected handshake (HTTP ${response.status})`;
try {
const body = await response.json() as { error?: string };
if (body.error) errorMessage = body.error;
} catch { /* ignore */ }
db.delete(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.run();
return reply.code(502).send({ error: errorMessage, statusCode: 502 });
}
// Parse the remote's instanceName from the response body so the
// federation panel renders a friendly label. Tolerate omission and
// non-JSON bodies — same pattern as performHandshake and /peer/initiate.
let remoteInstanceName: string | null = null;
try {
const body = (await response.json()) as { instanceName?: string | null };
if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) {
remoteInstanceName = body.instanceName;
}
} catch {
// Non-JSON body — leave null.
}
db.update(schema.federationPeers)
.set({ status: 'active', lastSeenAt: now, instanceName: remoteInstanceName, approvalToken: null })
.where(eq(schema.federationPeers.id, peerId))
.run();
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, id))
.run();
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
onPeerActivated(peerId, 'approval_handshake').catch(err =>
console.error('[federation] onPeerActivated from /approval-requests/:id/approve failed:', err)
);
const peer = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.get();
return reply.code(200).send({ success: true, peer: peer ? sanitizePeer(peer) : undefined });
} catch (err: unknown) {
db.delete(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.run();
const message = err instanceof Error ? err.message : 'Unknown error';
if (err instanceof DOMException && err.name === 'TimeoutError') {
return reply.code(504).send({
error: 'Remote instance did not respond within 10 seconds',
statusCode: 504,
});
}
return reply.code(502).send({
error: `Failed to reach remote instance: ${message}`,
statusCode: 502,
});
}
}, },
); );
// ─── POST /api/federation/approval-requests/:id/deny ─────────────────────── // ─── POST /api/federation/approval-requests/:id/deny ───────────────────────
// Direction-branched: inbound rows hit the remote's /peer/denied endpoint
// (existing behavior preserved); outbound rows fan out denied notifications
// to subscribers and cascade-delete the queue row.
app.post<{ Params: { id: string } }>( app.post<{ Params: { id: string } }>(
'/api/federation/approval-requests/:id/deny', '/api/federation/approval-requests/:id/deny',
{ preHandler: [authenticate, requireAdmin] }, { preHandler: [authenticate, requireAdmin] },
@@ -1370,64 +1765,188 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: 'Approval request not found', statusCode: 404 }); return reply.code(404).send({ error: 'Approval request not found', statusCode: 404 });
} }
const ourOrigin = getOurOrigin(); if (approvalReq.direction === 'outbound') {
const denialBody = JSON.stringify({ return await handleOutboundDeny(approvalReq, reply);
origin: ourOrigin,
reason: 'denied_by_admin' as const,
message: 'Request denied by admin',
});
const headers = buildFederationHeaders(denialBody, approvalReq.hmacSecret, ourOrigin);
let notificationSent = false;
try {
const response = await fetch(`${approvalReq.origin}/api/federation/peer/denied`, {
method: 'POST',
headers,
body: denialBody,
signal: AbortSignal.timeout(10_000),
});
notificationSent = response.ok;
} catch {
// Network error
} }
if (!notificationSent) { return await handleInboundDeny(approvalReq, reply);
return reply.code(502).send({ },
error: 'Denial notification could not be delivered to the remote instance. The request is still pending — you can retry or wait for it to expire.', );
statusCode: 502,
});
}
const existingPeer = db // ─── GET /api/federation/peering-subscriptions ─────────────────────────────
// User-facing: list the requesting user's pending outbound peering
// subscriber rows joined to their parent peer_approval_requests. Used by the
// pending-peering UI surface to show "you have a peering with X waiting on
// your admin's approval" rows.
app.get(
'/api/federation/peering-subscriptions',
{ preHandler: [authenticate] },
async (request, reply) => {
const db = getDb();
const userId = request.userId;
const rows = db
.select({
id: schema.peerApprovalSubscribers.id,
requestId: schema.peerApprovalSubscribers.requestId,
peerOrigin: schema.peerApprovalRequests.origin,
peerInstanceName: schema.peerApprovalRequests.instanceName,
triggerReason: schema.peerApprovalSubscribers.triggerReason,
triggerTarget: schema.peerApprovalSubscribers.triggerTarget,
createdAt: schema.peerApprovalSubscribers.createdAt,
})
.from(schema.peerApprovalSubscribers)
.innerJoin(
schema.peerApprovalRequests,
eq(schema.peerApprovalRequests.id, schema.peerApprovalSubscribers.requestId),
)
.where(eq(schema.peerApprovalSubscribers.userId, userId))
.orderBy(desc(schema.peerApprovalSubscribers.createdAt))
.all();
return reply.send({ subscriptions: rows });
},
);
// ─── DELETE /api/federation/peering-subscriptions/:id ──────────────────────
// User-facing: cancel one of the requesting user's pending peering
// subscriptions. Authorization: subscriber.userId must match request.userId.
// If this was the last subscriber for its parent request, the parent
// cascade-deletes too (avoids zombie outbound rows in the admin queue).
// No notification is created for the canceller (per spec §4.3 (iii)).
app.delete<{ Params: { id: string } }>(
'/api/federation/peering-subscriptions/:id',
{ preHandler: [authenticate] },
async (request, reply) => {
const db = getDb();
const { id } = request.params;
const userId = request.userId;
const sub = db
.select() .select()
.from(schema.federationPeers) .from(schema.peerApprovalSubscribers)
.where(eq(schema.federationPeers.origin, approvalReq.origin)) .where(eq(schema.peerApprovalSubscribers.id, id))
.get(); .get();
if (!sub) {
if (!existingPeer) { return reply.code(404).send({ error: 'subscription_not_found', statusCode: 404 });
db.insert(schema.federationPeers).values({ }
id: generateSnowflake(), if (sub.userId !== userId) {
origin: approvalReq.origin, return reply.code(403).send({ error: 'forbidden', statusCode: 403 });
instanceName: approvalReq.instanceName,
hmacSecret: approvalReq.hmacSecret,
status: 'rejected',
createdAt: Date.now(),
}).run();
} else if (existingPeer.status !== 'active') {
db.update(schema.federationPeers)
.set({ status: 'rejected' })
.where(eq(schema.federationPeers.id, existingPeer.id))
.run();
} }
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); db.delete(schema.peerApprovalSubscribers)
.where(eq(schema.peerApprovalSubscribers.id, id))
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, id))
.run(); .run();
return reply.code(200).send({ success: true }); // If the row we just removed was the last subscriber on its parent
// peer_approval_request, cascade-delete the parent. The admin queue
// refreshes via federation_peers_changed.
const remaining = db
.select({ id: schema.peerApprovalSubscribers.id })
.from(schema.peerApprovalSubscribers)
.where(eq(schema.peerApprovalSubscribers.requestId, sub.requestId))
.all();
if (remaining.length === 0) {
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, sub.requestId))
.run();
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
}
connectionManager.sendToUser(userId, { type: 'peering_subscription_changed' as const });
return reply.send({ success: true });
},
);
// ─── GET /api/federation/peering-notifications ─────────────────────────────
// User-facing: list the requesting user's terminal-state peering
// notifications (kind='approved'|'denied'|'expired'). Optional ?unread=1
// filter narrows to rows where readAt IS NULL. Ordered DESC by createdAt
// (newest first).
app.get<{ Querystring: { unread?: string } }>(
'/api/federation/peering-notifications',
{ preHandler: [authenticate] },
async (request, reply) => {
const db = getDb();
const userId = request.userId;
const unread = request.query?.unread === '1';
const whereClause = unread
? and(
eq(schema.peerApprovalNotifications.userId, userId),
isNull(schema.peerApprovalNotifications.readAt),
)
: eq(schema.peerApprovalNotifications.userId, userId);
const notifications = db
.select({
id: schema.peerApprovalNotifications.id,
kind: schema.peerApprovalNotifications.kind,
peerOrigin: schema.peerApprovalNotifications.peerOrigin,
triggerReason: schema.peerApprovalNotifications.triggerReason,
triggerTarget: schema.peerApprovalNotifications.triggerTarget,
createdAt: schema.peerApprovalNotifications.createdAt,
readAt: schema.peerApprovalNotifications.readAt,
})
.from(schema.peerApprovalNotifications)
.where(whereClause)
.orderBy(desc(schema.peerApprovalNotifications.createdAt))
.all();
return reply.send({ notifications });
},
);
// ─── POST /api/federation/peering-notifications/:id/read ───────────────────
// User-facing: mark a single peering notification as read. Authorization:
// notification.userId must match request.userId.
app.post<{ Params: { id: string } }>(
'/api/federation/peering-notifications/:id/read',
{ preHandler: [authenticate] },
async (request, reply) => {
const db = getDb();
const { id } = request.params;
const userId = request.userId;
const notif = db
.select()
.from(schema.peerApprovalNotifications)
.where(eq(schema.peerApprovalNotifications.id, id))
.get();
if (!notif) {
return reply.code(404).send({ error: 'notification_not_found', statusCode: 404 });
}
if (notif.userId !== userId) {
return reply.code(403).send({ error: 'forbidden', statusCode: 403 });
}
db.update(schema.peerApprovalNotifications)
.set({ readAt: Date.now() })
.where(eq(schema.peerApprovalNotifications.id, id))
.run();
return reply.send({ success: true });
},
);
// ─── POST /api/federation/peering-notifications/read-all ───────────────────
// User-facing: mark all the requesting user's unread peering notifications
// as read. Already-read rows are NOT touched (their readAt is preserved).
// Returns the count of rows affected for UI feedback.
app.post(
'/api/federation/peering-notifications/read-all',
{ preHandler: [authenticate] },
async (request, reply) => {
const db = getDb();
const userId = request.userId;
const result = db
.update(schema.peerApprovalNotifications)
.set({ readAt: Date.now() })
.where(
and(
eq(schema.peerApprovalNotifications.userId, userId),
isNull(schema.peerApprovalNotifications.readAt),
),
)
.run();
return reply.send({ success: true, count: result.changes });
}, },
); );
+13 -1
View File
@@ -178,7 +178,12 @@ async function handleFederatedFriendRequest(
} }
// 2. ensurePeered — block until 'active', or surface peer status as error // 2. ensurePeered — block until 'active', or surface peer status as error
const peering = await ensurePeered(peerOrigin); const peering = await ensurePeered(peerOrigin, {
kind: 'user_action',
userId: sender.id,
reason: 'friend_add',
target: `${baseName}@${targetDomain}`,
});
if (peering.status === 'rejected') { if (peering.status === 'rejected') {
return reply.code(403).send({ error: 'peer_rejected', statusCode: 403, domain: targetDomain }); return reply.code(403).send({ error: 'peer_rejected', statusCode: 403, domain: targetDomain });
} }
@@ -195,6 +200,13 @@ async function handleFederatedFriendRequest(
} }
return reply.code(409).send({ error: 'peer_pending', statusCode: 409, domain: targetDomain }); return reply.code(409).send({ error: 'peer_pending', statusCode: 409, domain: targetDomain });
} }
if (peering.status === 'admin_required') {
return reply.code(409).send({
error: 'peer_pending_local_admin',
statusCode: 409,
domain: targetDomain,
});
}
// peering.status === 'active' — continue // peering.status === 'active' — continue
// 3. Lookup // 3. Lookup
@@ -520,6 +520,7 @@ export const CALL_PEERING_TIMEOUT_MS = 3_000;
export type CallRelayFailureReason = export type CallRelayFailureReason =
| 'peer_rejected' | 'peer_rejected'
| 'peer_awaiting_approval' | 'peer_awaiting_approval'
| 'peer_admin_required'
| 'peer_transient_failure' | 'peer_transient_failure'
| 'post_failed'; | 'post_failed';
@@ -547,6 +548,7 @@ export function mapCallReasonToEventReason(reason: CallRelayFailureReason): DmCa
switch (reason) { switch (reason) {
case 'peer_rejected': return 'peer_rejected'; case 'peer_rejected': return 'peer_rejected';
case 'peer_awaiting_approval': return 'peer_awaiting_approval'; case 'peer_awaiting_approval': return 'peer_awaiting_approval';
case 'peer_admin_required': return 'peer_transient_failure'; // gate-unreachable from system intent; defensive map
case 'peer_transient_failure': return 'peer_transient_failure'; case 'peer_transient_failure': return 'peer_transient_failure';
case 'post_failed': return 'peer_transient_failure'; // 4xx looks transient to users case 'post_failed': return 'peer_transient_failure'; // 4xx looks transient to users
} }
@@ -583,14 +585,14 @@ export async function sendCallRelay(
if (!peer) { if (!peer) {
// ─── Non-blocking mode (typing): warm up in background, do not POST ── // ─── Non-blocking mode (typing): warm up in background, do not POST ──
if (timeoutMs === 0) { if (timeoutMs === 0) {
ensurePeered(targetPeerOrigin).catch(err => { ensurePeered(targetPeerOrigin, { kind: 'system' }).catch(err => {
console.warn('[federation] typing-triggered background handshake:', targetPeerOrigin, err); console.warn('[federation] typing-triggered background handshake:', targetPeerOrigin, err);
}); });
return { ok: false, reason: 'peer_transient_failure', error: 'peer not active' }; return { ok: false, reason: 'peer_transient_failure', error: 'peer not active' };
} }
// ─── Race ensurePeered against the deadline ── // ─── Race ensurePeered against the deadline ──
const raced = await racePeering(targetPeerOrigin, timeoutMs); const raced = await racePeering(targetPeerOrigin, timeoutMs, { kind: 'system' });
switch (raced.status) { switch (raced.status) {
case 'active': case 'active':
@@ -607,6 +609,8 @@ export async function sendCallRelay(
return { ok: false, reason: 'peer_rejected', error: raced.error }; return { ok: false, reason: 'peer_rejected', error: raced.error };
case 'pending': case 'pending':
return { ok: false, reason: 'peer_awaiting_approval', error: raced.error }; return { ok: false, reason: 'peer_awaiting_approval', error: raced.error };
case 'admin_required':
return { ok: false, reason: 'peer_admin_required', error: raced.error };
case 'failed': case 'failed':
return { ok: false, reason: 'peer_transient_failure', error: raced.error }; return { ok: false, reason: 'peer_transient_failure', error: raced.error };
case 'timeout': case 'timeout':
@@ -0,0 +1,275 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { generateSnowflake, setWorkerId } from './snowflake.js';
setWorkerId(1);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
schema,
}));
// Disable the relay so syncPeerMutationLog is a no-op (no fetches).
vi.mock('./federationOutbox.js', () => ({
isFederationRelayEnabled: () => false,
}));
vi.mock('./federationAuth.js', () => ({
getOurOrigin: () => 'https://local.example',
buildFederationHeaders: (_body: string, _secret: string, _origin: string) => ({
'Content-Type': 'application/json',
'X-Federation-Origin': _origin,
}),
generateHmacSecret: () => 'mock-hmac-secret',
}));
vi.mock('../routes/federation.js', () => ({
processRelayEvents: vi.fn().mockResolvedValue({ accepted: [], rejected: [], undeliverable: [] }),
validateOrigin: (raw: string) => {
try {
const url = new URL(raw);
return url.origin;
} catch {
return null;
}
},
}));
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToAdmins: vi.fn(),
sendToUser: vi.fn(),
getAllOnlineUserIds: () => [],
sendToDmMembers: vi.fn(),
evictFederatedCallsForHost: vi.fn().mockReturnValue(0),
},
}));
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
const statements = sqlText.split(/-->\s*statement-breakpoint/);
for (const stmt of statements) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedUser(id: string, username: string): void {
testDb.insert(schema.users).values({
id,
username,
passwordHash: 'x',
createdAt: Date.now(),
}).run();
}
function seedActivePeer(id: string, origin: string): void {
testDb.insert(schema.federationPeers).values({
id,
origin,
hmacSecret: 'secret',
status: 'active',
lastSyncedAt: Date.now(), // non-zero so startupBootstrapSync wouldn't pick it up
createdAt: Date.now(),
}).run();
}
describe('onPeerActivated — outbound subscriber fanout', () => {
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
sqlite.close();
});
// ─── Test 10 ────────────────────────────────────────────────────────────
// onPeerActivated with outbound queue + 2 subscribers → fans out approved
// notifications to each subscriber, deletes parent (cascade clears subs),
// and sends peering_notification_received WS to each subscriber.
it('fans out approved notifications to all subscribers and deletes parent', async () => {
seedUser('user1', 'alice');
seedUser('user2', 'bob');
seedActivePeer('peer-active', 'https://orbit.example');
const parentId = generateSnowflake();
const now = Date.now();
testDb.insert(schema.peerApprovalRequests).values({
id: parentId,
origin: 'https://orbit.example',
direction: 'outbound',
instanceName: null,
hmacSecret: null,
requestedAt: now,
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
approvalToken: null,
}).run();
testDb.insert(schema.peerApprovalSubscribers).values([
{
id: generateSnowflake(),
requestId: parentId,
userId: 'user1',
triggerReason: 'friend_add',
triggerTarget: 'someone@orbit.example',
createdAt: now,
},
{
id: generateSnowflake(),
requestId: parentId,
userId: 'user2',
triggerReason: 'space_join',
triggerTarget: 'space-x',
createdAt: now,
},
]).run();
const { onPeerActivated } = await import('./federationPeerActivation.js');
await onPeerActivated('peer-active', 'accept_new');
// 2 approved notifications written.
const notifs = testDb.select().from(schema.peerApprovalNotifications).all();
expect(notifs).toHaveLength(2);
expect(notifs.every(n => n.kind === 'approved')).toBe(true);
expect(notifs.every(n => n.peerOrigin === 'https://orbit.example')).toBe(true);
expect(notifs.every(n => n.readAt === null)).toBe(true);
const userIds = notifs.map(n => n.userId).sort();
expect(userIds).toEqual(['user1', 'user2']);
const u1Notif = notifs.find(n => n.userId === 'user1');
expect(u1Notif?.triggerReason).toBe('friend_add');
expect(u1Notif?.triggerTarget).toBe('someone@orbit.example');
const u2Notif = notifs.find(n => n.userId === 'user2');
expect(u2Notif?.triggerReason).toBe('space_join');
expect(u2Notif?.triggerTarget).toBe('space-x');
// Parent deleted, subscribers cascade-deleted.
const remainingParents = testDb.select().from(schema.peerApprovalRequests).all();
expect(remainingParents).toHaveLength(0);
const remainingSubs = testDb.select().from(schema.peerApprovalSubscribers).all();
expect(remainingSubs).toHaveLength(0);
// peering_notification_received WS sent to each subscriber.
const { connectionManager } = await import('../ws/handler.js');
expect(connectionManager.sendToUser).toHaveBeenCalledWith('user1', {
type: 'peering_notification_received',
kind: 'approved',
});
expect(connectionManager.sendToUser).toHaveBeenCalledWith('user2', {
type: 'peering_notification_received',
kind: 'approved',
});
// Exactly two sendToUser calls (one per subscriber).
const sendToUserCalls = vi.mocked(connectionManager.sendToUser).mock.calls;
const peeringCalls = sendToUserCalls.filter(
c => (c[1] as { type?: string }).type === 'peering_notification_received',
);
expect(peeringCalls).toHaveLength(2);
// sendToAdmins fired (the standard onPeerActivated broadcast).
expect(connectionManager.sendToAdmins).toHaveBeenCalledWith({
type: 'federation_peers_changed',
});
});
// ─── Test 11 ────────────────────────────────────────────────────────────
// onPeerActivated with no outbound queue → no-op fanout (no notifications,
// no sendToUser calls, no errors). The standard sendToAdmins broadcast
// still fires (that's onPeerActivated's own concern, not the fanout's).
it('is a no-op when no outbound queue row exists for the activated origin', async () => {
seedActivePeer('peer-no-queue', 'https://orphan.example');
const { onPeerActivated } = await import('./federationPeerActivation.js');
await expect(onPeerActivated('peer-no-queue', 'health_check_recovery')).resolves.toBeUndefined();
const notifs = testDb.select().from(schema.peerApprovalNotifications).all();
expect(notifs).toHaveLength(0);
const { connectionManager } = await import('../ws/handler.js');
const sendToUserCalls = vi.mocked(connectionManager.sendToUser).mock.calls;
const peeringCalls = sendToUserCalls.filter(
c => (c[1] as { type?: string }).type === 'peering_notification_received',
);
expect(peeringCalls).toHaveLength(0);
});
// ─── Test 12 ────────────────────────────────────────────────────────────
// CRITICAL CORRECTNESS PROPERTY: fanout runs regardless of activation
// path. Here we use reason='initiate_accepted' (admin-initiated via
// /peer/initiate, NOT via the outbound queue approval handler) and assert
// the fanout still clears subscribers and notifies them. This proves the
// centralized cleanup works for any activation path — not just the
// outbound-approve flow.
it('fans out and clears even when activated via admin-initiated path (initiate_accepted reason)', async () => {
seedUser('user1', 'alice');
seedActivePeer('peer-admin-initiated', 'https://orbit.example');
const parentId = generateSnowflake();
const now = Date.now();
testDb.insert(schema.peerApprovalRequests).values({
id: parentId,
origin: 'https://orbit.example',
direction: 'outbound',
instanceName: null,
hmacSecret: null,
requestedAt: now,
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
approvalToken: null,
}).run();
testDb.insert(schema.peerApprovalSubscribers).values({
id: generateSnowflake(),
requestId: parentId,
userId: 'user1',
triggerReason: 'friend_add',
triggerTarget: 'someone@orbit.example',
createdAt: now,
}).run();
const { onPeerActivated } = await import('./federationPeerActivation.js');
// 'initiate_accepted' = admin used /peer/initiate, bypassing the queue
// approval handler entirely. The fanout MUST still run.
await onPeerActivated('peer-admin-initiated', 'initiate_accepted');
// Subscriber was notified.
const notifs = testDb.select().from(schema.peerApprovalNotifications).all();
expect(notifs).toHaveLength(1);
expect(notifs[0]!.kind).toBe('approved');
expect(notifs[0]!.userId).toBe('user1');
expect(notifs[0]!.peerOrigin).toBe('https://orbit.example');
expect(notifs[0]!.triggerReason).toBe('friend_add');
expect(notifs[0]!.triggerTarget).toBe('someone@orbit.example');
// Parent + subscriber cascade-cleared.
const remainingParents = testDb.select().from(schema.peerApprovalRequests).all();
expect(remainingParents).toHaveLength(0);
const remainingSubs = testDb.select().from(schema.peerApprovalSubscribers).all();
expect(remainingSubs).toHaveLength(0);
// WS event sent to the subscriber.
const { connectionManager } = await import('../ws/handler.js');
expect(connectionManager.sendToUser).toHaveBeenCalledWith('user1', {
type: 'peering_notification_received',
kind: 'approved',
});
});
});
@@ -3,6 +3,7 @@ import * as schema from '../db/schema.js';
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { isFederationRelayEnabled } from './federationOutbox.js'; import { isFederationRelayEnabled } from './federationOutbox.js';
import { buildFederationHeaders, getOurOrigin } from './federationAuth.js'; import { buildFederationHeaders, getOurOrigin } from './federationAuth.js';
import { generateSnowflake } from './snowflake.js';
import type { FederationRelayEvent } from '@backspace/shared'; import type { FederationRelayEvent } from '@backspace/shared';
export type PeerActivationReason = export type PeerActivationReason =
@@ -50,6 +51,7 @@ export async function onPeerActivated(
try { try {
resetOutboxBackoff(peerId); resetOutboxBackoff(peerId);
await syncPeerMutationLog(peerId, reason); await syncPeerMutationLog(peerId, reason);
await fanoutOutboundSubscribers(peerId);
const { connectionManager } = await import('../ws/handler.js'); const { connectionManager } = await import('../ws/handler.js');
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
} catch (err) { } catch (err) {
@@ -173,6 +175,96 @@ export async function syncPeerMutationLog(
} }
} }
/**
* Fan out approved-notifications to all subscribers of any outbound
* peer_approval_requests row matching this activated peer's origin, then
* cascade-delete the parent row (which clears subscriber rows via the
* schema's onDelete: 'cascade').
*
* Single-source-of-truth cleanup hook for outbound subscribers. Runs from
* inside onPeerActivated so EVERY activation path triggers it, regardless
* of how the peer became active (queue approval, /peer/initiate,
* autoAccept=1 remote, mutual-approval token verification).
*
* Critical correctness invariant: cleanup hangs off status→active, NOT off
* the local admin's approve action. When the remote also gates, our peer
* row goes to awaiting_approval first; subscribers must remain queued
* until the remote also approves and the peer fully activates.
*
* No-op when no outbound queue row exists for the origin (the common case
* for non-gated peerings).
*
* Does NOT broadcast federation_peers_changed itself — onPeerActivated
* does that after this returns, so the queue-change signal is unified
* with the peer-state-change signal admins already receive.
*/
async function fanoutOutboundSubscribers(peerId: string): Promise<void> {
const db = getDb();
const peer = db
.select({ origin: schema.federationPeers.origin })
.from(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.get();
if (!peer) return;
const parent = db
.select()
.from(schema.peerApprovalRequests)
.where(
and(
eq(schema.peerApprovalRequests.origin, peer.origin),
eq(schema.peerApprovalRequests.direction, 'outbound'),
),
)
.get();
if (!parent) return;
const subscribers = db
.select()
.from(schema.peerApprovalSubscribers)
.where(eq(schema.peerApprovalSubscribers.requestId, parent.id))
.all();
const now = Date.now();
const { connectionManager } = await import('../ws/handler.js');
for (const sub of subscribers) {
db.insert(schema.peerApprovalNotifications)
.values({
id: generateSnowflake(),
userId: sub.userId,
kind: 'approved',
peerOrigin: peer.origin,
triggerReason: sub.triggerReason,
triggerTarget: sub.triggerTarget,
createdAt: now,
readAt: null,
})
.run();
connectionManager.sendToUser(sub.userId, {
type: 'peering_notification_received' as const,
kind: 'approved',
});
// The subscriber row is about to cascade-delete; tell the user's UI to
// refetch its pending list so the now-stale row disappears.
connectionManager.sendToUser(sub.userId, {
type: 'peering_subscription_changed' as const,
});
}
// Cascade-deletes subscriber rows via onDelete: 'cascade'.
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, parent.id))
.run();
if (subscribers.length > 0) {
console.log(
`[federation] fanoutOutboundSubscribers(${peerId}) approved ${subscribers.length} subscriber notification${subscribers.length === 1 ? '' : 's'} for ${peer.origin}`,
);
}
}
/** /**
* Startup bootstrap — scan for freshly-peered rows (status='active', lastSyncedAt=0) * Startup bootstrap — scan for freshly-peered rows (status='active', lastSyncedAt=0)
* and run onPeerActivated for each. Replaces runInitialSyncForNewPeers. * and run onPeerActivated for each. Replaces runInitialSyncForNewPeers.
@@ -101,7 +101,7 @@ describe('performHandshake — approval token capture & clear', () => {
); );
const { ensurePeered } = await import('./federationPeering.js'); const { ensurePeered } = await import('./federationPeering.js');
const result = await ensurePeered('https://remote.example'); const result = await ensurePeered('https://remote.example', { kind: 'system' });
expect(result.status).toBe('pending'); expect(result.status).toBe('pending');
@@ -120,7 +120,7 @@ describe('performHandshake — approval token capture & clear', () => {
); );
const { ensurePeered } = await import('./federationPeering.js'); const { ensurePeered } = await import('./federationPeering.js');
const result = await ensurePeered('https://legacy.example'); const result = await ensurePeered('https://legacy.example', { kind: 'system' });
expect(result.status).toBe('pending'); expect(result.status).toBe('pending');
const peer = testDb.select().from(schema.federationPeers) const peer = testDb.select().from(schema.federationPeers)
@@ -135,7 +135,7 @@ describe('performHandshake — approval token capture & clear', () => {
); );
const { ensurePeered } = await import('./federationPeering.js'); const { ensurePeered } = await import('./federationPeering.js');
const result = await ensurePeered('https://empty.example'); const result = await ensurePeered('https://empty.example', { kind: 'system' });
expect(result.status).toBe('pending'); expect(result.status).toBe('pending');
const peer = testDb.select().from(schema.federationPeers) const peer = testDb.select().from(schema.federationPeers)
@@ -161,7 +161,7 @@ describe('performHandshake — approval token capture & clear', () => {
); );
const { ensurePeered } = await import('./federationPeering.js'); const { ensurePeered } = await import('./federationPeering.js');
const result = await ensurePeered('https://remote.example'); const result = await ensurePeered('https://remote.example', { kind: 'system' });
expect(result.status).toBe('active'); expect(result.status).toBe('active');
const peer = testDb.select().from(schema.federationPeers) const peer = testDb.select().from(schema.federationPeers)
@@ -98,7 +98,7 @@ describe('performHandshake — persist remote instanceName', () => {
const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js'); const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js');
_clearInFlightPeering(); _clearInFlightPeering();
const result = await ensurePeered('https://remote.example'); const result = await ensurePeered('https://remote.example', { kind: 'system' });
expect(result.status).toBe('active'); expect(result.status).toBe('active');
const row = testDb.select().from(schema.federationPeers) const row = testDb.select().from(schema.federationPeers)
@@ -117,7 +117,7 @@ describe('performHandshake — persist remote instanceName', () => {
const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js'); const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js');
_clearInFlightPeering(); _clearInFlightPeering();
const result = await ensurePeered('https://remote.example'); const result = await ensurePeered('https://remote.example', { kind: 'system' });
expect(result.status).toBe('active'); expect(result.status).toBe('active');
const row = testDb.select().from(schema.federationPeers) const row = testDb.select().from(schema.federationPeers)
@@ -136,7 +136,7 @@ describe('performHandshake — persist remote instanceName', () => {
const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js'); const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js');
_clearInFlightPeering(); _clearInFlightPeering();
const result = await ensurePeered('https://remote.example'); const result = await ensurePeered('https://remote.example', { kind: 'system' });
expect(result.status).toBe('active'); expect(result.status).toBe('active');
const row = testDb.select().from(schema.federationPeers) const row = testDb.select().from(schema.federationPeers)
@@ -0,0 +1,455 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { eq } from 'drizzle-orm';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { setWorkerId } from './snowflake.js';
setWorkerId(1);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
schema,
}));
vi.mock('./federationAuth.js', async () => {
const actual = await vi.importActual<typeof import('./federationAuth.js')>('./federationAuth.js');
return {
...actual,
getOurOrigin: () => 'https://local.example',
generateHmacSecret: () => 'mock-hmac-secret',
};
});
vi.mock('../routes/federation.js', () => ({
validateOrigin: (raw: string) => {
try {
const url = new URL(raw);
return url.origin;
} catch {
return null;
}
},
}));
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToAdmins: vi.fn(),
sendToUser: vi.fn(),
getAllOnlineUserIds: () => [],
},
}));
vi.mock('./federationPeerActivation.js', () => ({
onPeerActivated: vi.fn(async () => undefined),
onPeerDeactivated: vi.fn(async () => undefined),
}));
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
const statements = sqlText.split(/-->\s*statement-breakpoint/);
for (const stmt of statements) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedInstanceSettings(autoAcceptPeering: 0 | 1): void {
testDb.insert(schema.instanceSettings).values({
id: 1,
instanceName: 'Local Backspace',
autoAcceptPeering,
registrationOpen: 1,
updatedAt: Date.now(),
}).run();
}
function seedUser(id: string, username: string): void {
testDb.insert(schema.users).values({
id,
username,
passwordHash: 'x',
createdAt: Date.now(),
}).run();
}
describe('ensurePeered — outbound gate behavior across (autoAccept × peer-row × intent)', () => {
beforeEach(async () => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
const { _clearInFlightPeering } = await import('./federationPeering.js');
_clearInFlightPeering();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
sqlite.close();
});
// ─── Test 1 ─────────────────────────────────────────────────────────────
// autoAccept=1 + no peer + user_action → gate does NOT fire; pending peer
// row created; handshake runs as today.
it('autoAccept=1 + no peer + user_action → no gate; pending peer row created; handshake runs', async () => {
seedInstanceSettings(1);
seedUser('user1', 'alice');
// Stub fetch with a network failure so the handshake resolves as 'failed'
// (transient) without us needing a complete 200/202 response. The peer row
// is created BEFORE fetch in performHandshake, then deleted on transient
// failure. So we assert the row existed mid-flight by spying on fetch and
// capturing DB state at that point.
let peerRowsDuringHandshake: Array<{ status: string; origin: string }> = [];
vi.stubGlobal('fetch', vi.fn(async () => {
peerRowsDuringHandshake = testDb
.select({ status: schema.federationPeers.status, origin: schema.federationPeers.origin })
.from(schema.federationPeers)
.all();
throw new TypeError('fetch failed');
}));
const { ensurePeered } = await import('./federationPeering.js');
const result = await ensurePeered('https://orbit.example', {
kind: 'user_action',
userId: 'user1',
reason: 'friend_add',
target: 'bob@orbit.example',
});
// Handshake reached fetch (gate did not fire). Network failure → 'failed'.
expect(result.status).toBe('failed');
// Mid-handshake, the pending peer row existed.
expect(peerRowsDuringHandshake).toHaveLength(1);
expect(peerRowsDuringHandshake[0]!.status).toBe('pending');
expect(peerRowsDuringHandshake[0]!.origin).toBe('https://orbit.example');
// No outbound queue rows were created.
const parents = testDb.select().from(schema.peerApprovalRequests).all();
expect(parents).toHaveLength(0);
const subs = testDb.select().from(schema.peerApprovalSubscribers).all();
expect(subs).toHaveLength(0);
});
// ─── Test 2 ─────────────────────────────────────────────────────────────
// autoAccept=0 + no peer + user_action → gate fires; outbound queue parent
// + subscriber created; returns 'admin_required'; no peer row created.
it('autoAccept=0 + no peer + user_action → queues outbound row + subscriber; no peer row; admin_required', async () => {
seedInstanceSettings(0);
seedUser('user1', 'alice');
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const { ensurePeered } = await import('./federationPeering.js');
const result = await ensurePeered('https://orbit.example', {
kind: 'user_action',
userId: 'user1',
reason: 'friend_add',
target: 'bob@orbit.example',
});
expect(result.status).toBe('admin_required');
// Parent row created.
const parents = testDb.select().from(schema.peerApprovalRequests).all();
expect(parents).toHaveLength(1);
expect(parents[0]!.direction).toBe('outbound');
expect(parents[0]!.origin).toBe('https://orbit.example');
expect(parents[0]!.hmacSecret).toBeNull();
// Subscriber row created.
const subs = testDb.select().from(schema.peerApprovalSubscribers).all();
expect(subs).toHaveLength(1);
expect(subs[0]!.userId).toBe('user1');
expect(subs[0]!.triggerReason).toBe('friend_add');
expect(subs[0]!.triggerTarget).toBe('bob@orbit.example');
expect(subs[0]!.requestId).toBe(parents[0]!.id);
// No federation_peers row created; no outbound POST attempted.
const peers = testDb.select().from(schema.federationPeers).all();
expect(peers).toHaveLength(0);
expect(fetchSpy).not.toHaveBeenCalled();
});
// ─── Test 3 ─────────────────────────────────────────────────────────────
// autoAccept=0 + no peer + system intent → gate fires; no queue row created;
// zero subscribers; returns 'admin_required'.
it('autoAccept=0 + no peer + system intent → admin_required; no queue, no subscribers', async () => {
seedInstanceSettings(0);
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const { ensurePeered } = await import('./federationPeering.js');
const result = await ensurePeered('https://orbit.example', { kind: 'system' });
expect(result.status).toBe('admin_required');
const parents = testDb.select().from(schema.peerApprovalRequests).all();
expect(parents).toHaveLength(0);
const subs = testDb.select().from(schema.peerApprovalSubscribers).all();
expect(subs).toHaveLength(0);
const peers = testDb.select().from(schema.federationPeers).all();
expect(peers).toHaveLength(0);
expect(fetchSpy).not.toHaveBeenCalled();
});
// ─── Test 4 ─────────────────────────────────────────────────────────────
// autoAccept=0 + existing active peer row + any intent → gate skipped;
// returns 'active' with existing peerId.
it('autoAccept=0 + existing active peer row → returns active; gate skipped', async () => {
seedInstanceSettings(0);
seedUser('user1', 'alice');
testDb.insert(schema.federationPeers).values({
id: 'peer-active',
origin: 'https://orbit.example',
hmacSecret: 'secret',
status: 'active',
createdAt: Date.now(),
}).run();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const { ensurePeered } = await import('./federationPeering.js');
const result = await ensurePeered('https://orbit.example', {
kind: 'user_action',
userId: 'user1',
reason: 'friend_add',
target: 'bob@orbit.example',
});
expect(result.status).toBe('active');
if (result.status === 'active') {
expect(result.peerId).toBe('peer-active');
}
// No queue rows; no outbound POST.
const parents = testDb.select().from(schema.peerApprovalRequests).all();
expect(parents).toHaveLength(0);
const subs = testDb.select().from(schema.peerApprovalSubscribers).all();
expect(subs).toHaveLength(0);
expect(fetchSpy).not.toHaveBeenCalled();
});
// ─── Test 5 ─────────────────────────────────────────────────────────────
// autoAccept=0 + existing pending peer row + user_action → gate skipped
// (existing path handles); existing dedup/inflight logic runs.
it('autoAccept=0 + existing pending peer row + user_action → gate skipped; reaches handshake (dedup path)', async () => {
seedInstanceSettings(0);
seedUser('user1', 'alice');
testDb.insert(schema.federationPeers).values({
id: 'peer-pending',
origin: 'https://orbit.example',
hmacSecret: 'old-secret',
status: 'pending',
createdAt: Date.now(),
}).run();
// The pending branch falls through to performHandshake which calls fetch.
// Stub it to fail transiently — the handshake will keep the existing peer
// row (existingPeerId is set, so it isn't deleted on failure).
const fetchSpy = vi.fn(async () => {
throw new TypeError('fetch failed');
});
vi.stubGlobal('fetch', fetchSpy);
const { ensurePeered } = await import('./federationPeering.js');
const result = await ensurePeered('https://orbit.example', {
kind: 'user_action',
userId: 'user1',
reason: 'friend_add',
target: 'bob@orbit.example',
});
// Reached performHandshake — failure mode is 'failed', NOT 'admin_required'.
expect(result.status).toBe('failed');
expect(fetchSpy).toHaveBeenCalled();
// Critically: the gate did NOT queue an outbound approval row.
const parents = testDb.select().from(schema.peerApprovalRequests).all();
expect(parents).toHaveLength(0);
const subs = testDb.select().from(schema.peerApprovalSubscribers).all();
expect(subs).toHaveLength(0);
// Existing pending peer row still present (existingPeerId path doesn't delete on failure).
const peers = testDb.select().from(schema.federationPeers).all();
expect(peers).toHaveLength(1);
expect(peers[0]!.id).toBe('peer-pending');
});
// ─── Test 6 ─────────────────────────────────────────────────────────────
// autoAccept=0 + existing rejected peer row + user_action → gate skipped;
// returns 'rejected' per existing branch (no queue creation).
it('autoAccept=0 + existing rejected peer row + user_action → returns rejected; gate does NOT override', async () => {
seedInstanceSettings(0);
seedUser('user1', 'alice');
testDb.insert(schema.federationPeers).values({
id: 'peer-rejected',
origin: 'https://orbit.example',
hmacSecret: 'secret',
status: 'rejected',
createdAt: Date.now(),
}).run();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const { ensurePeered } = await import('./federationPeering.js');
const result = await ensurePeered('https://orbit.example', {
kind: 'user_action',
userId: 'user1',
reason: 'friend_add',
target: 'bob@orbit.example',
});
expect(result.status).toBe('rejected');
// CRITICAL: no queue row created — the gate did NOT override the rejected branch.
const parents = testDb.select().from(schema.peerApprovalRequests).all();
expect(parents).toHaveLength(0);
const subs = testDb.select().from(schema.peerApprovalSubscribers).all();
expect(subs).toHaveLength(0);
expect(fetchSpy).not.toHaveBeenCalled();
// Rejected peer row unchanged.
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.id, 'peer-rejected')).get();
expect(peer?.status).toBe('rejected');
});
// ─── Test 7 ─────────────────────────────────────────────────────────────
// autoAccept=0 + same user calls twice with same target → upsert path; one
// parent row, one subscriber row, refreshed created_at.
it('autoAccept=0 + same user calls twice with same target → idempotent: 1 parent, 1 subscriber, refreshed createdAt', async () => {
seedInstanceSettings(0);
seedUser('user1', 'alice');
const { ensurePeered } = await import('./federationPeering.js');
const result1 = await ensurePeered('https://orbit.example', {
kind: 'user_action',
userId: 'user1',
reason: 'friend_add',
target: 'bob@orbit.example',
});
expect(result1.status).toBe('admin_required');
const subsAfterFirst = testDb.select().from(schema.peerApprovalSubscribers).all();
expect(subsAfterFirst).toHaveLength(1);
const firstCreatedAt = subsAfterFirst[0]!.createdAt;
// Wait a moment so the refreshed createdAt would differ.
await new Promise(r => setTimeout(r, 5));
const result2 = await ensurePeered('https://orbit.example', {
kind: 'user_action',
userId: 'user1',
reason: 'friend_add',
target: 'bob@orbit.example',
});
expect(result2.status).toBe('admin_required');
// Still exactly one parent row keyed on (origin, direction='outbound').
const parents = testDb.select().from(schema.peerApprovalRequests).all();
expect(parents).toHaveLength(1);
expect(parents[0]!.direction).toBe('outbound');
expect(parents[0]!.origin).toBe('https://orbit.example');
// Still exactly one subscriber keyed on (request_id, user_id, reason, target).
const subs = testDb.select().from(schema.peerApprovalSubscribers).all();
expect(subs).toHaveLength(1);
expect(subs[0]!.userId).toBe('user1');
expect(subs[0]!.triggerReason).toBe('friend_add');
expect(subs[0]!.triggerTarget).toBe('bob@orbit.example');
// createdAt was refreshed on the second call.
expect(subs[0]!.createdAt).toBeGreaterThan(firstCreatedAt);
});
// ─── Test 8 ─────────────────────────────────────────────────────────────
// autoAccept=0 + two users call with same target → one parent row, two
// subscriber rows.
it('autoAccept=0 + two users call with same target → 1 parent, 2 subscribers (m:n fanout)', async () => {
seedInstanceSettings(0);
seedUser('user1', 'alice');
seedUser('user2', 'carol');
const { ensurePeered } = await import('./federationPeering.js');
const result1 = await ensurePeered('https://orbit.example', {
kind: 'user_action',
userId: 'user1',
reason: 'friend_add',
target: 'bob@orbit.example',
});
expect(result1.status).toBe('admin_required');
const result2 = await ensurePeered('https://orbit.example', {
kind: 'user_action',
userId: 'user2',
reason: 'friend_add',
target: 'bob@orbit.example',
});
expect(result2.status).toBe('admin_required');
// Exactly one parent row.
const parents = testDb.select().from(schema.peerApprovalRequests).all();
expect(parents).toHaveLength(1);
expect(parents[0]!.origin).toBe('https://orbit.example');
expect(parents[0]!.direction).toBe('outbound');
// Two subscribers, both pointing at the same parent.
const subs = testDb.select().from(schema.peerApprovalSubscribers).all();
expect(subs).toHaveLength(2);
const userIds = subs.map(s => s.userId).sort();
expect(userIds).toEqual(['user1', 'user2']);
for (const sub of subs) {
expect(sub.requestId).toBe(parents[0]!.id);
expect(sub.triggerReason).toBe('friend_add');
expect(sub.triggerTarget).toBe('bob@orbit.example');
}
});
// ─── Test 9 ─────────────────────────────────────────────────────────────
// autoAccept=0 + user_action with reason='space_join' → subscriber row
// records correct reason and target.
it('autoAccept=0 + user_action reason=space_join → subscriber records correct reason/target', async () => {
seedInstanceSettings(0);
seedUser('user1', 'alice');
const { ensurePeered } = await import('./federationPeering.js');
const result = await ensurePeered('https://orbit.example', {
kind: 'user_action',
userId: 'user1',
reason: 'space_join',
target: 'space-abc-123',
});
expect(result.status).toBe('admin_required');
const subs = testDb.select().from(schema.peerApprovalSubscribers).all();
expect(subs).toHaveLength(1);
expect(subs[0]!.userId).toBe('user1');
expect(subs[0]!.triggerReason).toBe('space_join');
expect(subs[0]!.triggerTarget).toBe('space-abc-123');
const parents = testDb.select().from(schema.peerApprovalRequests).all();
expect(parents).toHaveLength(1);
expect(parents[0]!.direction).toBe('outbound');
expect(parents[0]!.origin).toBe('https://orbit.example');
});
});
@@ -51,9 +51,9 @@ describe('racePeering', () => {
status: 'active', status: 'active',
peerId: 'peer-1', peerId: 'peer-1',
})); }));
const result = await racePeering('https://example.com', 1_000, stub); const result = await racePeering('https://example.com', 1_000, { kind: 'system' }, stub);
expect(result).toEqual({ status: 'active', peerId: 'peer-1' }); expect(result).toEqual({ status: 'active', peerId: 'peer-1' });
expect(stub).toHaveBeenCalledWith('https://example.com'); expect(stub).toHaveBeenCalledWith('https://example.com', { kind: 'system' });
}); });
it('returns timeout when ensurePeered takes longer than the deadline', async () => { it('returns timeout when ensurePeered takes longer than the deadline', async () => {
@@ -61,7 +61,7 @@ describe('racePeering', () => {
const stub = vi.fn((): Promise<EnsurePeeredResult> => new Promise(() => { const stub = vi.fn((): Promise<EnsurePeeredResult> => new Promise(() => {
// Never resolves — simulates a slow handshake. // Never resolves — simulates a slow handshake.
})); }));
const racePromise = racePeering('https://example.com', 50, stub); const racePromise = racePeering('https://example.com', 50, { kind: 'system' }, stub);
await vi.advanceTimersByTimeAsync(50); await vi.advanceTimersByTimeAsync(50);
const result = await racePromise; const result = await racePromise;
expect(result).toEqual({ status: 'timeout' }); expect(result).toEqual({ status: 'timeout' });
@@ -73,7 +73,7 @@ describe('racePeering', () => {
status: 'rejected', status: 'rejected',
error: 'peer denied', error: 'peer denied',
})); }));
const result = await racePeering('https://example.com', 1_000, stub); const result = await racePeering('https://example.com', 1_000, { kind: 'system' }, stub);
expect(result).toEqual({ status: 'rejected', error: 'peer denied' }); expect(result).toEqual({ status: 'rejected', error: 'peer denied' });
}); });
@@ -83,7 +83,7 @@ describe('racePeering', () => {
const stub = vi.fn(() => new Promise<EnsurePeeredResult>((_, reject) => { const stub = vi.fn(() => new Promise<EnsurePeeredResult>((_, reject) => {
setTimeout(() => reject(new Error('late failure')), 30); setTimeout(() => reject(new Error('late failure')), 30);
})); }));
const racePromise = racePeering('https://example.com', 10, stub); const racePromise = racePeering('https://example.com', 10, { kind: 'system' }, stub);
await vi.advanceTimersByTimeAsync(10); await vi.advanceTimersByTimeAsync(10);
const result = await racePromise; const result = await racePromise;
expect(result).toEqual({ status: 'timeout' }); expect(result).toEqual({ status: 'timeout' });
@@ -104,7 +104,7 @@ describe('racePeering', () => {
const stub = vi.fn(async (): Promise<EnsurePeeredResult> => { const stub = vi.fn(async (): Promise<EnsurePeeredResult> => {
throw new Error('immediate handshake failure'); throw new Error('immediate handshake failure');
}); });
const result = await racePeering('https://example.com', 1_000, stub); const result = await racePeering('https://example.com', 1_000, { kind: 'system' }, stub);
expect(result).toEqual({ status: 'failed', error: 'immediate handshake failure' }); expect(result).toEqual({ status: 'failed', error: 'immediate handshake failure' });
// The handshake rejection was the race winner — no background warn should fire. // The handshake rejection was the race winner — no background warn should fire.
await Promise.resolve(); await Promise.resolve();
@@ -156,7 +156,7 @@ describe('ensurePeered needs_attention handling', () => {
const { ensurePeered } = await import('./federationPeering.js'); const { ensurePeered } = await import('./federationPeering.js');
const fetchSpy = vi.spyOn(globalThis, 'fetch'); const fetchSpy = vi.spyOn(globalThis, 'fetch');
const result = await ensurePeered('https://remote.example'); const result = await ensurePeered('https://remote.example', { kind: 'system' });
expect(result.status).toBe('rejected'); expect(result.status).toBe('rejected');
if (result.status === 'rejected') { if (result.status === 'rejected') {
@@ -103,7 +103,7 @@ describe('ensurePeered — refuses when unresolved inbound approval-request exis
const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js'); const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js');
_clearInFlightPeering(); _clearInFlightPeering();
const result = await ensurePeered('https://orbit.test'); const result = await ensurePeered('https://orbit.test', { kind: 'system' });
expect(result.status).toBe('rejected'); expect(result.status).toBe('rejected');
if (result.status === 'rejected') { if (result.status === 'rejected') {
@@ -126,7 +126,7 @@ describe('ensurePeered — refuses when unresolved inbound approval-request exis
const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js'); const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js');
_clearInFlightPeering(); _clearInFlightPeering();
const result = await ensurePeered('https://nopeer.test'); const result = await ensurePeered('https://nopeer.test', { kind: 'system' });
// Reached performHandshake — failure mode is 'failed' (network), NOT // Reached performHandshake — failure mode is 'failed' (network), NOT
// the pre-handshake 'rejected' from the new guard. // the pre-handshake 'rejected' from the new guard.
+153 -6
View File
@@ -1,10 +1,11 @@
import { getDb } from '../db/index.js'; import { getDb } from '../db/index.js';
import * as schema from '../db/schema.js'; import * as schema from '../db/schema.js';
import { eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { generateSnowflake } from './snowflake.js'; import { generateSnowflake } from './snowflake.js';
import { getOurOrigin, generateHmacSecret } from './federationAuth.js'; import { getOurOrigin, generateHmacSecret } from './federationAuth.js';
import { validateOrigin } from '../routes/federation.js'; import { validateOrigin } from '../routes/federation.js';
import { onPeerActivated, onPeerDeactivated } from './federationPeerActivation.js'; import { onPeerActivated, onPeerDeactivated } from './federationPeerActivation.js';
import type { EnsurePeeredCallerIntent } from '@backspace/shared';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
@@ -12,7 +13,8 @@ export type EnsurePeeredResult =
| { status: 'active'; peerId: string } | { status: 'active'; peerId: string }
| { status: 'rejected'; error: string } | { status: 'rejected'; error: string }
| { status: 'failed'; error: string } | { status: 'failed'; error: string }
| { status: 'pending'; error: string }; | { status: 'pending'; error: string }
| { status: 'admin_required'; error: string };
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -26,6 +28,106 @@ function getInstanceName(): string | undefined {
return row?.name ?? undefined; return row?.name ?? undefined;
} }
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
/**
* When the outbound gate fires for a `user_action` intent, upsert the
* `peer_approval_requests` (parent, keyed on origin+direction='outbound')
* and `peer_approval_subscribers` (per-user, keyed on parent+user+reason+target)
* rows, then broadcast WS events so the admin queue and the user's pending
* list refresh. Idempotent: repeated calls for the same (origin, user, reason,
* target) refresh `created_at` rather than creating duplicate rows.
*
* NOTE: parent row is created with `hmac_secret = NULL`. The CHECK constraint
* permits this for `direction='outbound'`. The approve handler generates fresh
* HMAC at the moment it actually sends `/peer/accept` to the remote.
*/
async function queueOutboundApproval(
origin: string,
intent: Extract<EnsurePeeredCallerIntent, { kind: 'user_action' }>,
): Promise<void> {
const db = getDb();
const now = Date.now();
// Upsert parent row keyed on (origin, direction='outbound').
let parent = db
.select()
.from(schema.peerApprovalRequests)
.where(
and(
eq(schema.peerApprovalRequests.origin, origin),
eq(schema.peerApprovalRequests.direction, 'outbound'),
),
)
.get();
if (!parent) {
const id = generateSnowflake();
db.insert(schema.peerApprovalRequests)
.values({
id,
origin,
direction: 'outbound',
instanceName: null,
hmacSecret: null,
requestedAt: now,
expiresAt: now + THIRTY_DAYS_MS,
approvalToken: null,
})
.run();
parent = db
.select()
.from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, id))
.get()!;
}
// Upsert subscriber row.
const existingSub = db
.select({ id: schema.peerApprovalSubscribers.id })
.from(schema.peerApprovalSubscribers)
.where(
and(
eq(schema.peerApprovalSubscribers.requestId, parent.id),
eq(schema.peerApprovalSubscribers.userId, intent.userId),
eq(schema.peerApprovalSubscribers.triggerReason, intent.reason),
eq(schema.peerApprovalSubscribers.triggerTarget, intent.target),
),
)
.get();
if (existingSub) {
db.update(schema.peerApprovalSubscribers)
.set({ createdAt: now })
.where(eq(schema.peerApprovalSubscribers.id, existingSub.id))
.run();
} else {
db.insert(schema.peerApprovalSubscribers)
.values({
id: generateSnowflake(),
requestId: parent.id,
userId: intent.userId,
triggerReason: intent.reason,
triggerTarget: intent.target,
createdAt: now,
})
.run();
}
// Broadcast: admins refresh queue; the calling user refreshes pending list.
// Dynamic import is the existing circular-dep workaround in this file
// (see ws/handler.js imports later). Keep it consistent.
const { connectionManager } = await import('../ws/handler.js');
connectionManager.sendToAdmins({
type: 'federation_approval_request_received' as const,
origin,
instanceName: undefined,
});
connectionManager.sendToUser(intent.userId, {
type: 'peering_subscription_changed' as const,
});
}
// ─── In-flight deduplication ───────────────────────────────────────────────── // ─── In-flight deduplication ─────────────────────────────────────────────────
const inFlightPeering = new Map<string, Promise<EnsurePeeredResult>>(); const inFlightPeering = new Map<string, Promise<EnsurePeeredResult>>();
@@ -40,7 +142,10 @@ const inFlightPeering = new Map<string, Promise<EnsurePeeredResult>>();
* - { status: 'rejected', error } — remote rejected auto-peering, or peer was revoked * - { status: 'rejected', error } — remote rejected auto-peering, or peer was revoked
* - { status: 'failed', error } — transient error (network, timeout), will retry * - { status: 'failed', error } — transient error (network, timeout), will retry
*/ */
export async function ensurePeered(origin: string): Promise<EnsurePeeredResult> { export async function ensurePeered(
origin: string,
intent: EnsurePeeredCallerIntent,
): Promise<EnsurePeeredResult> {
// Validate origin format // Validate origin format
const normalized = validateOrigin(origin); const normalized = validateOrigin(origin);
if (!normalized) { if (!normalized) {
@@ -93,10 +198,20 @@ export async function ensurePeered(origin: string): Promise<EnsurePeeredResult>
// The legitimate approval flow (routes/federation.ts /approval-requests/:id/ // The legitimate approval flow (routes/federation.ts /approval-requests/:id/
// approve) does NOT call ensurePeered — it deletes the approval-request first // approve) does NOT call ensurePeered — it deletes the approval-request first
// and does its own fetch — so this guard does not block legitimate approvals. // and does its own fetch — so this guard does not block legitimate approvals.
//
// direction='inbound' filter: the table is bidirectional as of the outbound
// peering gate (Task 3); outbound rows live in the same table and must NOT
// trigger this guard. The outbound gate below (`if (!existing)` block) is
// responsible for outbound row state.
const pendingInbound = db const pendingInbound = db
.select({ id: schema.peerApprovalRequests.id }) .select({ id: schema.peerApprovalRequests.id })
.from(schema.peerApprovalRequests) .from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.origin, normalized)) .where(
and(
eq(schema.peerApprovalRequests.origin, normalized),
eq(schema.peerApprovalRequests.direction, 'inbound'),
),
)
.get(); .get();
if (pendingInbound) { if (pendingInbound) {
@@ -106,6 +221,34 @@ export async function ensurePeered(origin: string): Promise<EnsurePeeredResult>
}; };
} }
// Outbound gate: when autoAcceptPeering=0, regular-user-initiated outbound
// becomes admin-approvable; system-initiated outbound is refused outright.
// Runs only when no peer row exists (existing rows already passed the gate
// when first created — toggling autoAccept later doesn't retroactively gate).
if (!existing) {
const settings = db
.select({ autoAcceptPeering: schema.instanceSettings.autoAcceptPeering })
.from(schema.instanceSettings)
.where(eq(schema.instanceSettings.id, 1))
.get();
const autoAccept = settings?.autoAcceptPeering ?? 1;
if (autoAccept === 0) {
if (intent.kind === 'user_action') {
await queueOutboundApproval(normalized, intent);
return {
status: 'admin_required',
error: 'Awaiting your admin\'s approval to initiate peering',
};
}
// system intent — refuse without queue
return {
status: 'admin_required',
error: 'Outbound peering requires admin approval on this instance',
};
}
}
// Deduplicate: if a handshake is already in flight, share the promise // Deduplicate: if a handshake is already in flight, share the promise
const inflight = inFlightPeering.get(normalized); const inflight = inFlightPeering.get(normalized);
if (inflight) { if (inflight) {
@@ -280,9 +423,13 @@ export function _clearInFlightPeering(): void {
export async function racePeering( export async function racePeering(
origin: string, origin: string,
timeoutMs: number, timeoutMs: number,
ensurePeeredFn: (origin: string) => Promise<EnsurePeeredResult> = ensurePeered, intent: EnsurePeeredCallerIntent,
ensurePeeredFn: (
origin: string,
intent: EnsurePeeredCallerIntent,
) => Promise<EnsurePeeredResult> = ensurePeered,
): Promise<EnsurePeeredResult | { status: 'timeout' }> { ): Promise<EnsurePeeredResult | { status: 'timeout' }> {
const handshake = ensurePeeredFn(origin); const handshake = ensurePeeredFn(origin, intent);
let timeoutHandle: ReturnType<typeof setTimeout> | undefined; let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<{ status: 'timeout' }>(resolve => { const timeoutPromise = new Promise<{ status: 'timeout' }>(resolve => {
@@ -496,7 +496,7 @@ async function resolvePendingPeers(): Promise<void> {
for (const { peerId, peerOrigin } of pendingWithEntries) { for (const { peerId, peerOrigin } of pendingWithEntries) {
console.log(`[federation-worker] Attempting auto-peer with ${peerOrigin}...`); console.log(`[federation-worker] Attempting auto-peer with ${peerOrigin}...`);
const result = await ensurePeered(peerOrigin); const result = await ensurePeered(peerOrigin, { kind: 'system' });
switch (result.status) { switch (result.status) {
case 'active': case 'active':
@@ -0,0 +1,383 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { eq } from 'drizzle-orm';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { setWorkerId } from './snowflake.js';
setWorkerId(2);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
vi.mock('../config.js', () => ({
config: {
domain: 'local.example',
port: 3000,
host: '0.0.0.0',
jwtSecret: 'test-secret-12345678901234567890123456789012',
maxUploadSize: 100 * 1024 * 1024,
registrationOpen: true,
uploadDir: '/tmp/backspace-test-uploads',
},
}));
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
const statements = sqlText.split(/-->\s*statement-breakpoint/);
for (const stmt of statements) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedUser(id: string, username: string): void {
testDb.insert(schema.users).values({
id,
username,
passwordHash: 'x',
displayName: username,
createdAt: Date.now(),
}).run();
}
describe('cleanupExpiredApprovalRequests', () => {
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
seedUser('alice', 'alice');
seedUser('bob', 'bob');
});
afterEach(() => {
vi.restoreAllMocks();
sqlite.close();
});
// Janitor expiry test A: outbound row with subscribers + past expiresAt →
// janitor writes kind='expired' notifications to each subscriber, cascade-deletes parent.
it('outbound expired row: fans out kind=expired notifications to each subscriber, then cascade-deletes parent', async () => {
const past = Date.now() - 1_000;
testDb.insert(schema.peerApprovalRequests).values({
id: 'req-out',
origin: 'https://orbit.example',
direction: 'outbound',
instanceName: 'Orbit',
hmacSecret: null,
requestedAt: past - 1000,
expiresAt: past,
approvalToken: null,
}).run();
testDb.insert(schema.peerApprovalSubscribers).values({
id: 'sub-alice',
requestId: 'req-out',
userId: 'alice',
triggerReason: 'friend_add',
triggerTarget: 'someone@orbit.example',
createdAt: past - 1000,
}).run();
testDb.insert(schema.peerApprovalSubscribers).values({
id: 'sub-bob',
requestId: 'req-out',
userId: 'bob',
triggerReason: 'space_join',
triggerTarget: 'invite-xyz',
createdAt: past - 500,
}).run();
// Outbound expiry must NOT make any network call. Spy on fetch to assert.
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(null, { status: 200 }),
);
const { cleanupExpiredApprovalRequests } = await import('./storageJanitor.js');
const before = Date.now();
const cleaned = await cleanupExpiredApprovalRequests();
const after = Date.now();
expect(fetchSpy).not.toHaveBeenCalled();
expect(cleaned).toBe(1);
// Parent deleted.
expect(testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, 'req-out')).get()).toBeUndefined();
// Subscribers cascade-deleted.
expect(testDb.select().from(schema.peerApprovalSubscribers)
.where(eq(schema.peerApprovalSubscribers.requestId, 'req-out')).all()).toHaveLength(0);
// Each subscriber received a kind='expired' notification preserving their
// trigger_reason / trigger_target.
const allNotifs = testDb.select().from(schema.peerApprovalNotifications).all();
expect(allNotifs).toHaveLength(2);
const aliceNotif = allNotifs.find(n => n.userId === 'alice');
const bobNotif = allNotifs.find(n => n.userId === 'bob');
expect(aliceNotif).toBeDefined();
expect(aliceNotif!.kind).toBe('expired');
expect(aliceNotif!.peerOrigin).toBe('https://orbit.example');
expect(aliceNotif!.triggerReason).toBe('friend_add');
expect(aliceNotif!.triggerTarget).toBe('someone@orbit.example');
expect(aliceNotif!.readAt).toBeNull();
expect(aliceNotif!.createdAt).toBeGreaterThanOrEqual(before);
expect(aliceNotif!.createdAt).toBeLessThanOrEqual(after);
expect(bobNotif).toBeDefined();
expect(bobNotif!.kind).toBe('expired');
expect(bobNotif!.peerOrigin).toBe('https://orbit.example');
expect(bobNotif!.triggerReason).toBe('space_join');
expect(bobNotif!.triggerTarget).toBe('invite-xyz');
expect(bobNotif!.readAt).toBeNull();
});
// Janitor expiry test B1: inbound row with past expiresAt + successful
// /peer/denied POST → janitor sends signed denial, then deletes the row.
// No local notifications are written for inbound.
it('inbound expired row: sends signed /peer/denied to origin and deletes on success', async () => {
const past = Date.now() - 1_000;
testDb.insert(schema.peerApprovalRequests).values({
id: 'req-in',
origin: 'https://orbit.example',
direction: 'inbound',
instanceName: 'Orbit',
hmacSecret: 'shared-secret',
requestedAt: past - 1000,
expiresAt: past,
approvalToken: null,
}).run();
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(null, { status: 200 }),
);
const { cleanupExpiredApprovalRequests } = await import('./storageJanitor.js');
const cleaned = await cleanupExpiredApprovalRequests();
expect(cleaned).toBe(1);
// Exactly one signed POST to the origin's /peer/denied endpoint.
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, init] = fetchSpy.mock.calls[0]!;
expect(url).toBe('https://orbit.example/api/federation/peer/denied');
expect(init?.method).toBe('POST');
const headers = init?.headers as Record<string, string>;
expect(headers['X-Federation-Signature']).toMatch(/^sha256=[0-9a-f]+$/);
expect(headers['X-Federation-Origin']).toBe('https://local.example');
expect(headers['X-Federation-Timestamp']).toMatch(/^\d+$/);
expect(headers['X-Federation-Nonce']).toBeDefined();
expect(headers['Content-Type']).toBe('application/json');
const body = JSON.parse(init?.body as string);
expect(body.origin).toBe('https://local.example');
expect(body.reason).toBe('expired');
expect(typeof body.message).toBe('string');
expect(body.message.length).toBeGreaterThan(0);
// Parent deleted.
expect(testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, 'req-in')).get()).toBeUndefined();
// No local notifications written for inbound.
expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0);
});
// Janitor expiry test B2: inbound row with past expiresAt + failed POST
// (network error or non-2xx) → janitor keeps the row for retry on the
// next cycle. No local notifications written.
it('inbound expired row: keeps row for retry when /peer/denied POST fails', async () => {
const past = Date.now() - 1_000;
testDb.insert(schema.peerApprovalRequests).values({
id: 'req-in-fail',
origin: 'https://orbit.example',
direction: 'inbound',
instanceName: 'Orbit',
hmacSecret: 'shared-secret',
requestedAt: past - 1000,
expiresAt: past,
approvalToken: null,
}).run();
// Simulate a 503 (non-ok response) — the row must remain.
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(null, { status: 503 }),
);
const { cleanupExpiredApprovalRequests } = await import('./storageJanitor.js');
const cleaned = await cleanupExpiredApprovalRequests();
expect(cleaned).toBe(0);
expect(fetchSpy).toHaveBeenCalledTimes(1);
// Row still present — eligible for retry next cycle.
expect(testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, 'req-in-fail')).get()).toBeDefined();
// No local notifications written.
expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0);
});
// Network-error variant of B2: thrown error (e.g. AbortError, DNS failure)
// is caught and treated as not-sent. Row must remain for retry.
it('inbound expired row: keeps row when fetch throws a network error', async () => {
const past = Date.now() - 1_000;
testDb.insert(schema.peerApprovalRequests).values({
id: 'req-in-throw',
origin: 'https://orbit.example',
direction: 'inbound',
instanceName: 'Orbit',
hmacSecret: 'shared-secret',
requestedAt: past - 1000,
expiresAt: past,
approvalToken: null,
}).run();
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('ECONNREFUSED'));
const { cleanupExpiredApprovalRequests } = await import('./storageJanitor.js');
const cleaned = await cleanupExpiredApprovalRequests();
expect(cleaned).toBe(0);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, 'req-in-throw')).get()).toBeDefined();
expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0);
});
// Mixed: not-yet-expired rows are not touched, regardless of direction.
it('does not touch rows whose expiresAt is in the future', async () => {
const future = Date.now() + 60_000;
testDb.insert(schema.peerApprovalRequests).values({
id: 'req-future-out',
origin: 'https://a.example',
direction: 'outbound',
instanceName: null,
hmacSecret: null,
requestedAt: Date.now(),
expiresAt: future,
approvalToken: null,
}).run();
testDb.insert(schema.peerApprovalSubscribers).values({
id: 'sub-future',
requestId: 'req-future-out',
userId: 'alice',
triggerReason: 'friend_add',
triggerTarget: 'a@a.example',
createdAt: Date.now(),
}).run();
const { cleanupExpiredApprovalRequests } = await import('./storageJanitor.js');
const cleaned = await cleanupExpiredApprovalRequests();
expect(cleaned).toBe(0);
// Row still present.
expect(testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, 'req-future-out')).get()).toBeDefined();
expect(testDb.select().from(schema.peerApprovalSubscribers)
.where(eq(schema.peerApprovalSubscribers.id, 'sub-future')).get()).toBeDefined();
expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0);
});
});
describe('cleanupReadPeeringNotifications', () => {
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
seedUser('alice', 'alice');
});
afterEach(() => {
vi.restoreAllMocks();
sqlite.close();
});
// Cleanup test: read notifications older than 30 days are deleted; unread
// and recent-read notifications are NOT deleted.
it('deletes only read-AND-old notifications; unread and recent-read survive', async () => {
const now = Date.now();
const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000;
const cutoffMargin = 60_000;
// (a) Read & older than 30 days → DELETE
testDb.insert(schema.peerApprovalNotifications).values({
id: 'old-read',
userId: 'alice',
kind: 'approved',
peerOrigin: 'https://a.example',
triggerReason: 'friend_add',
triggerTarget: 'a@a.example',
createdAt: now - THIRTY_DAYS - 5 * 60_000,
readAt: now - THIRTY_DAYS - cutoffMargin,
}).run();
// (b) Read & recent (< 30 days old) → KEEP
testDb.insert(schema.peerApprovalNotifications).values({
id: 'recent-read',
userId: 'alice',
kind: 'denied',
peerOrigin: 'https://b.example',
triggerReason: 'space_join',
triggerTarget: 'invite-y',
createdAt: now - 5 * 60_000,
readAt: now - 60_000,
}).run();
// (c) Unread (regardless of age) → KEEP
testDb.insert(schema.peerApprovalNotifications).values({
id: 'old-unread',
userId: 'alice',
kind: 'expired',
peerOrigin: 'https://c.example',
triggerReason: 'direct_message',
triggerTarget: 'c@c.example',
createdAt: now - THIRTY_DAYS - 10 * 60_000,
readAt: null,
}).run();
testDb.insert(schema.peerApprovalNotifications).values({
id: 'fresh-unread',
userId: 'alice',
kind: 'approved',
peerOrigin: 'https://d.example',
triggerReason: 'friend_add',
triggerTarget: 'd@d.example',
createdAt: now - 60_000,
readAt: null,
}).run();
const { cleanupReadPeeringNotifications } = await import('./storageJanitor.js');
const deleted = cleanupReadPeeringNotifications();
expect(deleted).toBe(1);
expect(testDb.select().from(schema.peerApprovalNotifications)
.where(eq(schema.peerApprovalNotifications.id, 'old-read')).get()).toBeUndefined();
expect(testDb.select().from(schema.peerApprovalNotifications)
.where(eq(schema.peerApprovalNotifications.id, 'recent-read')).get()).toBeDefined();
expect(testDb.select().from(schema.peerApprovalNotifications)
.where(eq(schema.peerApprovalNotifications.id, 'old-unread')).get()).toBeDefined();
expect(testDb.select().from(schema.peerApprovalNotifications)
.where(eq(schema.peerApprovalNotifications.id, 'fresh-unread')).get()).toBeDefined();
});
});
+115 -16
View File
@@ -4,6 +4,7 @@ import { and, eq, inArray, isNotNull, isNull, lt, lte } from 'drizzle-orm';
import { config } from '../config.js'; import { config } from '../config.js';
import { getDb, getRawDb, schema } from '../db/index.js'; import { getDb, getRawDb, schema } from '../db/index.js';
import { deleteUploadFile, deleteAttachmentFiles } from './fileCleanup.js'; import { deleteUploadFile, deleteAttachmentFiles } from './fileCleanup.js';
import { generateSnowflake } from './snowflake.js';
import type { StorageStats, StorageBreakdown, OrphanedFile, CleanupResult } from '@backspace/shared'; import type { StorageStats, StorageBreakdown, OrphanedFile, CleanupResult } from '@backspace/shared';
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp', '.avif']); const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp', '.avif']);
@@ -433,31 +434,92 @@ export function cleanupFederationFileQueue(): number {
/** /**
* Expire peer approval requests older than their expiresAt timestamp. * Expire peer approval requests older than their expiresAt timestamp.
* For each expired request, attempt to send a signed denial notification *
* to the requesting instance before deleting. If notification fails, * Outbound rows: before deletion, fan out kind='expired' notifications to
* leave the record for the next janitor cycle. * each subscriber so the original requester(s) can see the terminal state on
* next page load. Subscriber rows cascade-delete via FK when the parent row
* is removed. No network call — the remote was never told about our queued
* outbound request to begin with.
*
* Inbound rows: send a signed POST /peer/denied with reason='expired' to
* the requesting origin so it can update its UI. Only delete the row if the
* POST succeeded; otherwise keep it and retry on the next janitor cycle.
*
* No WebSocket broadcast is emitted on local expiry — offline users see the
* notification on next GET /api/federation/peering-notifications, matching
* the persistence guarantee of the notifications table.
*/ */
export async function cleanupExpiredApprovalRequests(): Promise<number> { export async function cleanupExpiredApprovalRequests(): Promise<number> {
const db = getDb(); const db = getDb();
const expired = db const expiredRequests = db
.select() .select()
.from(schema.peerApprovalRequests) .from(schema.peerApprovalRequests)
.where(lte(schema.peerApprovalRequests.expiresAt, Date.now())) .where(lte(schema.peerApprovalRequests.expiresAt, Date.now()))
.all(); .all();
if (expired.length === 0) return 0; if (expiredRequests.length === 0) return 0;
const { getOurOrigin, buildFederationHeaders } = await import('./federationAuth.js'); const { getOurOrigin, buildFederationHeaders } = await import('./federationAuth.js');
const { connectionManager } = await import('../ws/handler.js');
const ourOrigin = getOurOrigin(); const ourOrigin = getOurOrigin();
let cleaned = 0; const now = Date.now();
let deletedCount = 0;
for (const req of expiredRequests) {
if (req.direction === 'outbound') {
// Outbound expiry: fan out kind='expired' notifications to subscribers,
// then cascade-delete. No network call.
const subs = db
.select()
.from(schema.peerApprovalSubscribers)
.where(eq(schema.peerApprovalSubscribers.requestId, req.id))
.all();
for (const sub of subs) {
db.insert(schema.peerApprovalNotifications)
.values({
id: generateSnowflake(),
userId: sub.userId,
kind: 'expired',
peerOrigin: req.origin,
triggerReason: sub.triggerReason,
triggerTarget: sub.triggerTarget,
createdAt: now,
readAt: null,
})
.run();
// Subscriber row is about to cascade-delete; if the user is online,
// refresh their pending list AND notification list. Offline users see
// both on next page load via the GET endpoints (persistence guarantee).
connectionManager.sendToUser(sub.userId, {
type: 'peering_notification_received' as const,
kind: 'expired',
});
connectionManager.sendToUser(sub.userId, {
type: 'peering_subscription_changed' as const,
});
}
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, req.id))
.run();
deletedCount++;
continue;
}
// Inbound expiry: signed /peer/denied POST to origin; only delete on
// success. Preserved verbatim from pre-branch behavior.
if (!req.hmacSecret) {
// CHECK constraint guarantees inbound rows have hmac_secret; defensive guard.
console.warn(
`[storage-janitor] Inbound approval-request ${req.id} missing hmac_secret — skipping expiry notification`,
);
continue;
}
for (const req of expired) {
const denialBody = JSON.stringify({ const denialBody = JSON.stringify({
origin: ourOrigin, origin: ourOrigin,
reason: 'expired' as const, reason: 'expired' as const,
message: 'Request expired — no response from admin within 30 days', message: 'Request expired — no response from admin within 30 days',
}); });
const headers = buildFederationHeaders(denialBody, req.hmacSecret, ourOrigin); const headers = buildFederationHeaders(denialBody, req.hmacSecret, ourOrigin);
let sent = false; let sent = false;
@@ -478,13 +540,43 @@ export async function cleanupExpiredApprovalRequests(): Promise<number> {
db.delete(schema.peerApprovalRequests) db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, req.id)) .where(eq(schema.peerApprovalRequests.id, req.id))
.run(); .run();
cleaned++; deletedCount++;
} else { } else {
console.warn(`[storage-janitor] Failed to send expiry denial to ${req.origin} — will retry next cycle`); console.warn(
`[storage-janitor] Failed to send expiry denial to ${req.origin} — will retry next cycle`,
);
} }
} }
return cleaned; if (deletedCount > 0) {
console.log(`[storage-janitor] Deleted ${deletedCount} expired peer_approval_requests rows`);
}
return deletedCount;
}
/**
* Delete read peering notifications older than 30 days. Unread notifications
* are never auto-cleaned — the user must explicitly read or dismiss them.
*/
const NOTIFICATION_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
export function cleanupReadPeeringNotifications(): number {
const db = getDb();
const cutoff = Date.now() - NOTIFICATION_RETENTION_MS;
const result = db
.delete(schema.peerApprovalNotifications)
.where(
and(
isNotNull(schema.peerApprovalNotifications.readAt),
lte(schema.peerApprovalNotifications.readAt, cutoff),
),
)
.run();
if (result.changes > 0) {
console.log(`[storage-janitor] Deleted ${result.changes} read peering notifications older than 30 days`);
}
return result.changes;
} }
/** /**
@@ -608,7 +700,7 @@ export function cleanupSoftDeletedDmChannels(): number {
* - Stale file queue entries * - Stale file queue entries
* - Soft-deleted DM channels past grace period * - Soft-deleted DM channels past grace period
*/ */
export function runFederationJanitor(): void { export async function runFederationJanitor(): Promise<void> {
try { try {
const outbox = cleanupFederationOutbox(); const outbox = cleanupFederationOutbox();
const mutLog = cleanupFederationMutationLog(); const mutLog = cleanupFederationMutationLog();
@@ -622,14 +714,21 @@ export function runFederationJanitor(): void {
); );
} }
// Async: expire approval requests (sends network notifications) // Expire approval requests:
cleanupExpiredApprovalRequests().then((approvalExpired) => { // - Outbound rows fan out kind='expired' notifications to subscribers
// before cascade-delete (local DB only).
// - Inbound rows send a signed /peer/denied POST to the requesting
// origin; only delete on success, otherwise retry next cycle.
// Then sweep read peering notifications older than 30 days.
try {
const approvalExpired = await cleanupExpiredApprovalRequests();
if (approvalExpired > 0) { if (approvalExpired > 0) {
console.log(`[storage-janitor] Expired ${approvalExpired} peer approval request(s)`); console.log(`[storage-janitor] Expired ${approvalExpired} peer approval request(s)`);
} }
}).catch((err) => { cleanupReadPeeringNotifications();
} catch (err) {
console.error('[storage-janitor] Approval request expiry error:', err); console.error('[storage-janitor] Approval request expiry error:', err);
}); }
} catch (err) { } catch (err) {
console.error('[storage-janitor] Federation GC sweep error:', err); console.error('[storage-janitor] Federation GC sweep error:', err);
} }
+103
View File
@@ -467,6 +467,8 @@ export type ServerEvent =
| { type: 'federation_peer_active'; peerOrigin: string } | { type: 'federation_peer_active'; peerOrigin: string }
| { type: 'federation_peers_changed' } | { type: 'federation_peers_changed' }
| { type: 'federation_approval_request_received'; origin: string; instanceName?: string } | { type: 'federation_approval_request_received'; origin: string; instanceName?: string }
| { type: 'peering_subscription_changed' }
| { type: 'peering_notification_received'; kind: PeeringNotificationKind }
| { type: 'dm_owner_updated'; dmChannelId: string; newOwnerId: string } | { type: 'dm_owner_updated'; dmChannelId: string; newOwnerId: string }
| { type: 'pong' } | { type: 'pong' }
| { type: 'error'; message: string }; | { type: 'error'; message: string };
@@ -1026,3 +1028,104 @@ export interface FederationPeer {
rotationInProgress: boolean; rotationInProgress: boolean;
createdAt: number; createdAt: number;
} }
// ─── Outbound peering gate ──────────────────────────────────────────────────
/**
* Why a user-initiated federation action triggered the outbound peering gate.
* Recorded on `peer_approval_subscribers.trigger_reason` so admins can see
* the human-readable cause and the user can recover their original action
* after approval. Persisted as a string column with this exact set of values.
*/
export type PeeringTriggerReason = 'friend_add' | 'space_join' | 'direct_message';
/**
* Caller intent passed into `ensurePeered()`. The gate (when
* `autoAcceptPeering=0` and no peer row exists) branches on `kind`:
* - 'user_action': queue an outbound approval request and surface
* `admin_required` to the caller so the user sees a clear pending state.
* - 'system': skip queueing; surface `admin_required` so the calling
* subsystem (e.g. background relay) can fail loudly without spamming
* admin queues with rows nobody asked for.
*
* `target` is the human-readable target identifier the user acted on
* (e.g. `username@instance.example` for friend_add, the space invite code
* for space_join, the federated DM channel id for direct_message).
*/
export type EnsurePeeredCallerIntent =
| { kind: 'user_action'; userId: string; reason: PeeringTriggerReason; target: string }
| { kind: 'system' };
/**
* Terminal-state notification kinds delivered to subscribers when the
* outbound queue resolves. 'expired' is delivered by the storage janitor
* before it deletes an unresolved outbound queue row past `expiresAt`.
*/
export type PeeringNotificationKind = 'approved' | 'denied' | 'expired';
/**
* Per-user pending row joined from `peer_approval_subscribers` to its parent
* `peer_approval_requests`. Returned from
* `GET /api/federation/peering-subscriptions`. Used to render the user's own
* "waiting on admin" surface so they remember which actions are blocked.
*/
export interface PeeringSubscription {
id: string;
requestId: string;
peerOrigin: string;
peerInstanceName: string | null;
triggerReason: PeeringTriggerReason;
triggerTarget: string;
createdAt: number;
}
/**
* Terminal-state notification row returned from
* `GET /api/federation/peering-notifications`. Persists until the user
* explicitly reads (sets `readAt`) or the janitor cleans up read rows
* older than the retention window.
*/
export interface PeeringNotification {
id: string;
kind: PeeringNotificationKind;
peerOrigin: string;
triggerReason: PeeringTriggerReason;
triggerTarget: string;
createdAt: number;
readAt: number | null;
}
/**
* Subscriber summary embedded in the admin-facing approval request response
* for outbound rows. Lets the admin see which users are waiting on each
* outbound request without a separate fetch.
*/
export interface ApprovalRequestSubscriberSummary {
userId: string;
username: string;
triggerReason: PeeringTriggerReason;
triggerTarget: string;
}
/**
* Admin-facing approval request row returned from
* `GET /api/federation/approval-requests`. Inbound rows are remote
* instances asking to peer with us; outbound rows are local users asking
* us to peer with a remote instance. Outbound rows include `subscribers`
* so the admin can see who is waiting.
*/
export interface ApprovalRequest {
id: string;
direction: 'inbound' | 'outbound';
origin: string;
instanceName: string | null;
requestedAt: number;
expiresAt: number;
/**
* Subscriber summaries — present (and possibly empty array) only when
* `direction === 'outbound'`. ABSENT (`undefined`) when `direction === 'inbound'`.
* Inbound rows have no per-action context; the field is omitted from the server
* response, not set to `[]`.
*/
subscribers?: ApprovalRequestSubscriberSummary[];
}
+30 -9
View File
@@ -53,9 +53,12 @@ import type {
FederationIdentityDeleteRequest, FederationIdentityDeleteRequest,
FederationIdentityDeleteResponse, FederationIdentityDeleteResponse,
FederationPeer, FederationPeer,
ApprovalRequest,
PeeringSubscription,
PeeringNotification,
} from '@backspace/shared'; } from '@backspace/shared';
export type { FederationPeer }; export type { FederationPeer, ApprovalRequest, PeeringSubscription, PeeringNotification };
export class RateLimitError extends Error { export class RateLimitError extends Error {
readonly retryAfter: number; readonly retryAfter: number;
@@ -66,14 +69,6 @@ export class RateLimitError extends Error {
} }
} }
export interface ApprovalRequest {
id: string;
origin: string;
instanceName: string | null;
requestedAt: number;
expiresAt: number;
}
export class BackspaceApiClient { export class BackspaceApiClient {
readonly auth: { readonly auth: {
register: (data: RegisterRequest) => Promise<AuthResponse>; register: (data: RegisterRequest) => Promise<AuthResponse>;
@@ -230,6 +225,11 @@ export class BackspaceApiClient {
approvalRequests: () => Promise<{ requests: ApprovalRequest[] }>; approvalRequests: () => Promise<{ requests: ApprovalRequest[] }>;
approveRequest: (id: string) => Promise<{ success: boolean; peer?: FederationPeer }>; approveRequest: (id: string) => Promise<{ success: boolean; peer?: FederationPeer }>;
denyRequest: (id: string) => Promise<{ success: boolean }>; denyRequest: (id: string) => Promise<{ success: boolean }>;
peeringSubscriptions: () => Promise<{ subscriptions: PeeringSubscription[] }>;
cancelPeeringSubscription: (id: string) => Promise<{ success: boolean }>;
peeringNotifications: (unreadOnly?: boolean) => Promise<{ notifications: PeeringNotification[] }>;
markPeeringNotificationRead: (id: string) => Promise<{ success: boolean }>;
markAllPeeringNotificationsRead: () => Promise<{ success: boolean; count: number }>;
}; };
readonly admin: { readonly admin: {
@@ -697,6 +697,27 @@ export class BackspaceApiClient {
request<{ success: boolean }>( request<{ success: boolean }>(
'POST', `/federation/approval-requests/${id}/deny` 'POST', `/federation/approval-requests/${id}/deny`
), ),
peeringSubscriptions: () =>
request<{ subscriptions: PeeringSubscription[] }>(
'GET', '/federation/peering-subscriptions'
),
cancelPeeringSubscription: (id: string) =>
request<{ success: boolean }>(
'DELETE', `/federation/peering-subscriptions/${id}`
),
peeringNotifications: (unreadOnly = false) =>
request<{ notifications: PeeringNotification[] }>(
'GET',
`/federation/peering-notifications${unreadOnly ? '?unread=1' : ''}`,
),
markPeeringNotificationRead: (id: string) =>
request<{ success: boolean }>(
'POST', `/federation/peering-notifications/${id}/read`
),
markAllPeeringNotificationsRead: () =>
request<{ success: boolean; count: number }>(
'POST', '/federation/peering-notifications/read-all'
),
}; };
this.admin = { this.admin = {
@@ -7,6 +7,7 @@ import { mapServerErrorToMessage } from '../../utils/friendErrors';
import { useSpaceStore } from '../../stores/spaceStore'; import { useSpaceStore } from '../../stores/spaceStore';
import { useInstanceStore } from '../../stores/instanceStore'; import { useInstanceStore } from '../../stores/instanceStore';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
import { useFederationStore } from '../../stores/federationStore';
import { Avatar } from '../ui/Avatar'; import { Avatar } from '../ui/Avatar';
import { MemberListToggleButton } from '../layout/MemberListToggleButton'; import { MemberListToggleButton } from '../layout/MemberListToggleButton';
import { LoadingSpinner } from '../ui/LoadingSpinner'; import { LoadingSpinner } from '../ui/LoadingSpinner';
@@ -34,6 +35,17 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const addDmChannel = useSpaceStore((s) => s.addDmChannel); const addDmChannel = useSpaceStore((s) => s.addDmChannel);
// If the user clicked "Retry your friend request" in the Connections panel
// and we just navigated here, the federation store carries the original
// target. Switching to the Add tab makes the AddFriendTab mount, which then
// consumes the prefill into its query input. We only check on mount — the
// store value is one-shot (cleared by AddFriendTab on consume).
useEffect(() => {
if (useFederationStore.getState().pendingFriendAddPrefill) {
setActiveTab('add');
}
}, []);
const { const {
friends, friends,
requests, requests,
@@ -394,7 +406,13 @@ function AddFriendTab({
const fetchDiscoverUsers = useDiscoverStore((s) => s.fetchUsers); const fetchDiscoverUsers = useDiscoverStore((s) => s.fetchUsers);
const updateRelationship = useDiscoverStore((s) => s.updateRelationship); const updateRelationship = useDiscoverStore((s) => s.updateRelationship);
const [query, setQuery] = useState(''); const [query, setQuery] = useState(() => {
// Consume the federation store's pending friend-add prefill at mount so
// a Retry click in the Connections panel lands here with the original
// target already in the input. The consume call clears the store value
// so subsequent mounts (e.g. tab switching) start empty.
return useFederationStore.getState().consumePendingFriendAddPrefill() ?? '';
});
const [rawSearchResults, setRawSearchResults] = useState<TaggedUser[]>([]); const [rawSearchResults, setRawSearchResults] = useState<TaggedUser[]>([]);
const [searchLoading, setSearchLoading] = useState(false); const [searchLoading, setSearchLoading] = useState(false);
const [directAddLoading, setDirectAddLoading] = useState(false); const [directAddLoading, setDirectAddLoading] = useState(false);
@@ -1,9 +1,17 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import type { InstanceInfoResponse, FederationRegistryEntry } from '@backspace/shared'; import { useNavigate } from 'react-router-dom';
import type {
InstanceInfoResponse,
FederationRegistryEntry,
PeeringSubscription,
PeeringNotification,
PeeringTriggerReason,
} from '@backspace/shared';
import { useInstanceStore, DifferentPasswordError, isSelfOrigin } from '../../stores/instanceStore'; import { useInstanceStore, DifferentPasswordError, isSelfOrigin } from '../../stores/instanceStore';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
import { useFederationStore } from '../../stores/federationStore';
import { isElectron } from '../../platform/platform'; import { isElectron } from '../../platform/platform';
import { ConfirmDialog } from '../ui/ConfirmDialog'; import { ConfirmDialog } from '../ui/ConfirmDialog';
@@ -967,6 +975,311 @@ function sortEntries(entries: FederationRegistryEntry[], sortBy: SortBy): Federa
}); });
} }
// ─── Outbound peering gate helpers ──────────────────────────────────────────
function actionLabel(reason: PeeringTriggerReason): string {
switch (reason) {
case 'friend_add': return 'friend request';
case 'space_join': return 'space join';
case 'direct_message': return 'direct message';
}
}
function actionVerbPhrase(reason: PeeringTriggerReason, target: string): string {
switch (reason) {
case 'friend_add': return `Friend request to ${target}`;
case 'space_join': return `Join ${target}`;
case 'direct_message': return `Direct message to ${target}`;
}
}
// ─── Pending peering subscriptions section ──────────────────────────────────
function PendingSubscriptionRow({ subscription }: { subscription: PeeringSubscription }) {
const cancelPeeringSubscription = useFederationStore((s) => s.cancelPeeringSubscription);
const addToast = useUIStore((s) => s.addToast);
const [busy, setBusy] = useState(false);
const host = safeHost(subscription.peerOrigin);
const peerLabel = subscription.peerInstanceName || host;
const handleCancel = async () => {
setBusy(true);
try {
await cancelPeeringSubscription(subscription.id);
addToast('Peering request cancelled', 'success', 3000);
} catch (err) {
addToast(
`Failed to cancel: ${err instanceof Error ? err.message : 'Unknown error'}`,
'warning',
5000,
);
setBusy(false);
}
};
return (
<div className="bg-white/[0.02] rounded-md px-3 py-2.5 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm text-txt-primary truncate">
{actionVerbPhrase(subscription.triggerReason, subscription.triggerTarget)}
</div>
<div className="text-[11px] text-txt-tertiary truncate">
on <span className="text-txt-secondary">{peerLabel}</span>
{subscription.peerInstanceName && (
<span className="ml-1 text-txt-tertiary/70">({host})</span>
)}
</div>
</div>
<button
type="button"
onClick={handleCancel}
disabled={busy}
className="px-3 py-1.5 text-xs font-medium bg-white/[0.06] hover:bg-white/[0.1] text-txt-secondary rounded transition-colors shrink-0 disabled:opacity-50"
>
{busy ? 'Cancelling...' : 'Cancel'}
</button>
</div>
);
}
function PendingPeeringSubscriptionsSection() {
const subscriptions = useFederationStore((s) => s.peeringSubscriptions);
if (subscriptions.length === 0) return null;
return (
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
Pending Peering Approvals
</div>
<p className="text-xs text-txt-tertiary mb-2">
Your admin must approve before these requests can proceed.
</p>
<div className="rounded-lg bg-white/[0.02] p-3 space-y-2">
{subscriptions.map((s) => (
<PendingSubscriptionRow key={s.id} subscription={s} />
))}
</div>
</div>
);
}
// ─── Recent peering outcomes section ────────────────────────────────────────
function notificationAccentClasses(kind: PeeringNotification['kind']): {
surface: string;
iconBg: string;
iconColor: string;
} {
switch (kind) {
case 'approved':
return {
surface: 'bg-status-online/[0.06] border border-status-online/15',
iconBg: 'bg-status-online/15',
iconColor: 'text-status-online',
};
case 'denied':
return {
surface: 'bg-accent-rose/[0.06] border border-accent-rose/15',
iconBg: 'bg-accent-rose/15',
iconColor: 'text-txt-danger',
};
case 'expired':
return {
surface: 'bg-accent-amber/[0.06] border border-accent-amber/15',
iconBg: 'bg-accent-amber/15',
iconColor: 'text-accent-amber',
};
}
}
function NotificationIcon({ kind, className }: { kind: PeeringNotification['kind']; className: string }) {
// approved: check, denied: cross, expired: clock
if (kind === 'approved') {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className={className}>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
</svg>
);
}
if (kind === 'denied') {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className={className}>
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
);
}
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className={className}>
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" />
</svg>
);
}
function PeeringNotificationCard({
notification,
onRetry,
}: {
notification: PeeringNotification;
onRetry: (notification: PeeringNotification) => void;
}) {
const markPeeringNotificationRead = useFederationStore((s) => s.markPeeringNotificationRead);
const addToast = useUIStore((s) => s.addToast);
const [busy, setBusy] = useState(false);
const host = safeHost(notification.peerOrigin);
const accent = notificationAccentClasses(notification.kind);
const handleDismiss = async () => {
setBusy(true);
try {
await markPeeringNotificationRead(notification.id);
} catch (err) {
addToast(
`Failed to dismiss: ${err instanceof Error ? err.message : 'Unknown error'}`,
'warning',
5000,
);
setBusy(false);
}
};
// Retry is only meaningful on approved notifications, and the gate currently
// only wires friend_add. space_join and direct_message reach the gate via
// backend paths that aren't user-initiated end-to-end yet, so we hide Retry
// for those rather than promise an action we cannot deliver.
const showRetry =
notification.kind === 'approved' && notification.triggerReason === 'friend_add';
let primaryText: string;
if (notification.kind === 'approved') {
primaryText = `Your peering request to ${host} was approved by your admin.`;
} else if (notification.kind === 'denied') {
primaryText = `Your peering request to ${host} was denied by your admin.`;
} else {
primaryText = `Your peering request to ${host} expired without admin action.`;
}
const contextText =
notification.kind === 'approved' && notification.triggerReason !== 'friend_add'
? `Original action: ${actionVerbPhrase(notification.triggerReason, notification.triggerTarget)}.`
: `Original action: ${actionVerbPhrase(notification.triggerReason, notification.triggerTarget)}`;
return (
<div className={`rounded-md px-3 py-2.5 ${accent.surface}`}>
<div className="flex items-start gap-2.5">
<div className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 ${accent.iconBg}`}>
<NotificationIcon kind={notification.kind} className={accent.iconColor} />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm text-txt-primary">{primaryText}</div>
<div className="text-[11px] text-txt-tertiary mt-0.5">{contextText}</div>
<div className="flex items-center gap-2 mt-2 flex-wrap">
{showRetry && (
<button
type="button"
onClick={() => onRetry(notification)}
className="px-3 py-1.5 text-xs font-medium bg-status-online/15 text-status-online hover:bg-status-online/25 rounded transition-colors"
>
Retry your {actionLabel(notification.triggerReason)}
</button>
)}
<button
type="button"
onClick={handleDismiss}
disabled={busy}
className="px-3 py-1.5 text-xs font-medium bg-white/[0.06] hover:bg-white/[0.1] text-txt-secondary rounded transition-colors disabled:opacity-50"
>
{busy ? 'Dismissing...' : 'Dismiss'}
</button>
</div>
</div>
</div>
</div>
);
}
function RecentPeeringOutcomesSection() {
const notifications = useFederationStore((s) => s.peeringNotifications);
const markAllPeeringNotificationsRead = useFederationStore((s) => s.markAllPeeringNotificationsRead);
const setPendingFriendAddPrefill = useFederationStore((s) => s.setPendingFriendAddPrefill);
const closeModal = useUIStore((s) => s.closeModal);
const setShowDms = useUIStore((s) => s.setShowDms);
const setMobileTab = useUIStore((s) => s.setMobileTab);
const isMobile = useUIStore((s) => s.isMobile);
const addToast = useUIStore((s) => s.addToast);
const navigate = useNavigate();
const [bulkBusy, setBulkBusy] = useState(false);
if (notifications.length === 0) return null;
const handleRetry = (notification: PeeringNotification) => {
if (notification.triggerReason !== 'friend_add') {
// Defensive — Retry button is only rendered for friend_add. Bail
// silently if a future change widens this without updating the handler.
return;
}
// Set the prefill side-channel before navigating so AddFriendTab finds
// it on its initial render.
setPendingFriendAddPrefill(notification.triggerTarget);
// Mark this notification read in the background — the user has acted on
// it. Use the per-id endpoint so other unread notifications stay visible.
void useFederationStore.getState().markPeeringNotificationRead(notification.id);
// Close the settings modal that hosts this panel.
closeModal();
if (isMobile) {
// Mobile: jump to the DMs/Friends tab so MobileShell renders FriendsPage.
setMobileTab('dms');
} else {
// Desktop: route to /channels/@me — AppLayout's effect calls
// setShowDms(true) for the @me path, and MainContent renders FriendsPage
// when no DM channel is selected.
setShowDms(true);
navigate('/channels/@me');
}
};
const handleDismissAll = async () => {
setBulkBusy(true);
try {
await markAllPeeringNotificationsRead();
} catch (err) {
addToast(
`Failed to dismiss all: ${err instanceof Error ? err.message : 'Unknown error'}`,
'warning',
5000,
);
setBulkBusy(false);
}
};
return (
<div>
<div className="flex items-center justify-between mb-1.5">
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider">
Recent Peering Outcomes
</div>
{notifications.length > 1 && (
<button
type="button"
onClick={handleDismissAll}
disabled={bulkBusy}
className="text-[11px] text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50"
>
{bulkBusy ? 'Dismissing...' : 'Dismiss all'}
</button>
)}
</div>
<div className="space-y-2">
{notifications.map((n) => (
<PeeringNotificationCard key={n.id} notification={n} onRetry={handleRetry} />
))}
</div>
</div>
);
}
// ─── Main component ────────────────────────────────────────────────────────── // ─── Main component ──────────────────────────────────────────────────────────
export function ConnectedInstances() { export function ConnectedInstances() {
@@ -974,6 +1287,17 @@ export function ConnectedInstances() {
const registry = useInstanceStore((s) => s.registry); const registry = useInstanceStore((s) => s.registry);
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const refetchPeeringSubscriptions = useFederationStore((s) => s.refetchPeeringSubscriptions);
const refetchPeeringNotifications = useFederationStore((s) => s.refetchPeeringNotifications);
// Hydrate the outbound-peering-gate user surfaces on mount. WS events
// (peering_subscription_changed / peering_notification_received) will keep
// them fresh while the panel stays mounted.
useEffect(() => {
void refetchPeeringSubscriptions();
void refetchPeeringNotifications();
}, [refetchPeeringSubscriptions, refetchPeeringNotifications]);
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [filter, setFilter] = useState<StatusFilter>('all'); const [filter, setFilter] = useState<StatusFilter>('all');
const [sortBy, setSortBy] = useState<SortBy>('dateAdded'); const [sortBy, setSortBy] = useState<SortBy>('dateAdded');
@@ -1025,7 +1349,15 @@ export function ConnectedInstances() {
: null; : null;
return ( return (
<div> <div className="space-y-5">
{/* Terminal-state outcomes first — newly resolved requests warrant the
user's attention (especially approvals they can now retry). */}
<RecentPeeringOutcomesSection />
{/* Active waiting state. */}
<PendingPeeringSubscriptionsSection />
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5"> <div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
Connected Instances Connected Instances
</div> </div>
@@ -1109,6 +1441,7 @@ export function ConnectedInstances() {
</button> </button>
)} )}
</div> </div>
</div>
</div> </div>
); );
} }
@@ -693,16 +693,44 @@ function PendingApprovals({ onCountChange }: { onCountChange?: (count: number) =
<div className="text-xs text-txt-tertiary py-2">Loading...</div> <div className="text-xs text-txt-tertiary py-2">Loading...</div>
)} )}
{requests.map((req) => { {requests.map((req) => {
const name = req.instanceName || new URL(req.origin).host; const isOutbound = req.direction === 'outbound';
let name = req.instanceName || '';
if (!name) {
try {
name = new URL(req.origin).host;
} catch {
name = req.origin;
}
}
const subCount = req.subscribers?.length ?? 0;
const titleText = isOutbound
? `${name}${subCount} ${subCount === 1 ? 'user wants' : 'users want'} us to peer`
: name;
return ( return (
<div key={req.id} className="bg-white/[0.02] rounded-md px-3 py-2.5"> <div key={req.id} className="bg-white/[0.02] rounded-md px-3 py-2.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-medium text-txt-primary truncate">{name}</div> <div className="text-sm font-medium text-txt-primary truncate">{titleText}</div>
<div className="text-[11px] text-txt-tertiary truncate">{req.origin}</div> <div className="text-[11px] text-txt-tertiary truncate">{req.origin}</div>
<div className="text-[11px] text-txt-tertiary mt-0.5"> <div className="text-[11px] text-txt-tertiary mt-0.5">
Requested {formatRelativeTime(req.requestedAt)} Requested {formatRelativeTime(req.requestedAt)}
</div> </div>
{isOutbound && req.subscribers && req.subscribers.length > 0 && (
<div className="mt-1.5 space-y-0.5">
{req.subscribers.map((sub) => (
<div
key={`${sub.userId}:${sub.triggerReason}:${sub.triggerTarget}`}
className="text-[11px] text-txt-tertiary"
>
<span className="font-medium text-txt-secondary">{sub.username}</span>
{' — '}
{sub.triggerReason === 'friend_add' && `friend-add to ${sub.triggerTarget}`}
{sub.triggerReason === 'space_join' && `wants to join ${sub.triggerTarget}`}
{sub.triggerReason === 'direct_message' && `wants to DM ${sub.triggerTarget}`}
</div>
))}
</div>
)}
</div> </div>
<div className="flex items-center gap-2 shrink-0 ml-3"> <div className="flex items-center gap-2 shrink-0 ml-3">
<button <button
@@ -740,22 +768,35 @@ function PendingApprovals({ onCountChange }: { onCountChange?: (count: number) =
})} })}
</div> </div>
{confirmAction && ( {confirmAction && (() => {
<ConfirmDialog const isOutbound = confirmAction.request.direction === 'outbound';
isOpen={true} const targetName = confirmAction.request.instanceName || confirmAction.request.origin;
onClose={() => { if (!actionLoading) setConfirmAction(null); }} const subCount = confirmAction.request.subscribers?.length ?? 0;
onConfirm={handleConfirm} const description =
title={confirmAction.type === 'approve' ? 'Approve Peering Request' : 'Deny Peering Request'} confirmAction.type === 'approve'
description={ ? isOutbound
confirmAction.type === 'approve' ? `This will initiate a peering handshake with ${targetName} on behalf of the ${subCount} requesting ${subCount === 1 ? 'user' : 'users'}. The remote instance must be reachable.`
? `This will initiate a peering handshake with ${confirmAction.request.instanceName || confirmAction.request.origin}. The remote instance must be reachable.` : `This will initiate a peering handshake with ${targetName}. The remote instance must be reachable.`
: `This will deny the request and block future auto-peering requests from ${confirmAction.request.instanceName || confirmAction.request.origin}. You can unblock them later from the rejected peers list.` : isOutbound
} ? `This will deny the outbound peering request and notify the requesting ${subCount === 1 ? 'user' : 'users'}. They can re-trigger the request from their friend list.`
confirmLabel={confirmAction.type === 'approve' ? 'Approve' : 'Deny'} : `This will deny the request and block future auto-peering requests from ${targetName}. You can unblock them later from the rejected peers list.`;
variant={confirmAction.type === 'approve' ? 'warning' : 'danger'} const confirmLabel =
loading={!!actionLoading} confirmAction.type === 'approve'
/> ? isOutbound ? 'Approve & Peer' : 'Approve'
)} : isOutbound ? 'Deny & Notify' : 'Deny';
return (
<ConfirmDialog
isOpen={true}
onClose={() => { if (!actionLoading) setConfirmAction(null); }}
onConfirm={handleConfirm}
title={confirmAction.type === 'approve' ? 'Approve Peering Request' : 'Deny Peering Request'}
description={description}
confirmLabel={confirmLabel}
variant={confirmAction.type === 'approve' ? 'warning' : 'danger'}
loading={!!actionLoading}
/>
);
})()}
</div> </div>
); );
} }
+28
View File
@@ -14,6 +14,7 @@ import { getActiveRoom } from './useLiveKit';
import { useUIStore } from '../stores/uiStore'; import { useUIStore } from '../stores/uiStore';
import { useActivityStore } from '../stores/activityStore'; import { useActivityStore } from '../stores/activityStore';
import { useDiscoverStore } from '../stores/discoverStore'; import { useDiscoverStore } from '../stores/discoverStore';
import { useFederationStore } from '../stores/federationStore';
// ─── Rejected peer origins (for unreachable member indicators) ─────────────── // ─── Rejected peer origins (for unreachable member indicators) ───────────────
const rejectedPeerOrigins = new Set<string>(); const rejectedPeerOrigins = new Set<string>();
@@ -781,6 +782,33 @@ function handleEvent(origin: string, event: ServerEvent): void {
break; break;
} }
case 'peering_subscription_changed': {
// The user's pending peering-subscription set changed (admin approved/
// denied/expired the parent request, or the user cancelled a row from
// another tab). Refetch — the server is the source of truth.
void useFederationStore.getState().refetchPeeringSubscriptions();
break;
}
case 'peering_notification_received': {
// Terminal-state outcome arrived for one of the user's outbound peering
// requests. Refetch the notifications list and surface a transient
// toast — the inline list in the Connections panel is the persistent
// surface; the toast is opportunistic for online users.
void useFederationStore.getState().refetchPeeringNotifications();
const message =
event.kind === 'approved'
? 'Your peering request was approved'
: event.kind === 'denied'
? 'Your peering request was denied'
: 'Your peering request expired';
// uiStore exposes 'info' | 'warning' | 'success' — use 'success' for
// approved, 'warning' for denied/expired (no error severity exists).
const severity: 'success' | 'warning' = event.kind === 'approved' ? 'success' : 'warning';
useUIStore.getState().addToast(message, severity, 4500);
break;
}
case 'dm_message_deleted': case 'dm_message_deleted':
if (!isHome && !activePeerOrigins.has(origin)) break; if (!isHome && !activePeerOrigins.has(origin)) break;
removeMessage(event.messageId, event.dmChannelId); removeMessage(event.messageId, event.dmChannelId);
@@ -0,0 +1,96 @@
import { create } from 'zustand';
import type { PeeringSubscription, PeeringNotification } from '@backspace/shared';
import { api } from '../api/client';
/**
* Outbound peering gate user-facing state.
*
* Holds the current user's pending peering subscriptions (rows they own in
* `peer_approval_subscribers` joined to their parent peering request) and
* their unread terminal-state notifications. Both lists are scoped to the
* home instance API client — federation gating is a home-instance concern;
* remote instances do not surface their own outbound queues to this user.
*
* The retry deep-link side-channel (`pendingFriendAddPrefill`) carries the
* trigger target from a Retry click in the Connections panel into the
* `AddFriendTab` on the Friends page. The consuming component reads and
* clears the value on mount.
*/
interface FederationState {
peeringSubscriptions: PeeringSubscription[];
peeringNotifications: PeeringNotification[];
/**
* Side-channel for the friend-add Retry deep-link. The Connections panel
* sets this to the original `triggerTarget` (e.g. `alice@orbit.tld`),
* navigates to the Friends page, and the AddFriendTab consumes + clears
* it on mount to prefill its query input.
*/
pendingFriendAddPrefill: string | null;
refetchPeeringSubscriptions: () => Promise<void>;
refetchPeeringNotifications: () => Promise<void>;
cancelPeeringSubscription: (id: string) => Promise<void>;
markPeeringNotificationRead: (id: string) => Promise<void>;
markAllPeeringNotificationsRead: () => Promise<void>;
setPendingFriendAddPrefill: (value: string | null) => void;
consumePendingFriendAddPrefill: () => string | null;
}
export const useFederationStore = create<FederationState>((set, get) => ({
peeringSubscriptions: [],
peeringNotifications: [],
pendingFriendAddPrefill: null,
refetchPeeringSubscriptions: async () => {
try {
const { subscriptions } = await api.federation.peeringSubscriptions();
set({ peeringSubscriptions: subscriptions });
} catch (err) {
console.error('Failed to load peering subscriptions:', err);
}
},
refetchPeeringNotifications: async () => {
try {
// unreadOnly=true — UI only ever shows unread terminal notifications.
const { notifications } = await api.federation.peeringNotifications(true);
set({ peeringNotifications: notifications });
} catch (err) {
console.error('Failed to load peering notifications:', err);
}
},
cancelPeeringSubscription: async (id) => {
await api.federation.cancelPeeringSubscription(id);
// Optimistic local update — the WS `peering_subscription_changed` event
// will arrive shortly and reconcile, but we drop the row immediately so
// the UI feels responsive.
set((state) => ({
peeringSubscriptions: state.peeringSubscriptions.filter((s) => s.id !== id),
}));
},
markPeeringNotificationRead: async (id) => {
await api.federation.markPeeringNotificationRead(id);
set((state) => ({
peeringNotifications: state.peeringNotifications.filter((n) => n.id !== id),
}));
},
markAllPeeringNotificationsRead: async () => {
await api.federation.markAllPeeringNotificationsRead();
set({ peeringNotifications: [] });
},
setPendingFriendAddPrefill: (value) => set({ pendingFriendAddPrefill: value }),
consumePendingFriendAddPrefill: () => {
const value = get().pendingFriendAddPrefill;
if (value !== null) {
set({ pendingFriendAddPrefill: null });
}
return value;
},
}));
+2
View File
@@ -20,6 +20,8 @@ export function mapServerErrorToMessage(
case 'already_friends': return "You're already friends with this user."; case 'already_friends': return "You're already friends with this user.";
case 'peer_pending_approval': case 'peer_pending_approval':
return "The remote instance's admin needs to approve federation. Try again later."; return "The remote instance's admin needs to approve federation. Try again later.";
case 'peer_pending_local_admin':
return "Your admin needs to approve federation with this instance. You'll see your request in Connections settings.";
case 'peer_pending': case 'peer_pending':
return 'Connecting to the remote instance — try again in a moment.'; return 'Connecting to the remote instance — try again in a moment.';
case 'incoming_request_exists': case 'incoming_request_exists':