Merge branch 'feat/outbound-peering-gate'
This commit is contained in:
+21
-12
@@ -361,26 +361,31 @@ The temporary password is shown exactly once in the admin UI -- the UsersPanel d
|
||||
|
||||
### Peering Approval Requests
|
||||
|
||||
All admin-only. Only present when `autoAcceptPeering` is `false` and incoming peering requests are queued.
|
||||
All admin-only. Present when `autoAcceptPeering` is `false`. The queue holds **both directions**:
|
||||
- **Inbound** rows — remote instances asking to peer with us.
|
||||
- **Outbound** rows — local users who initiated peering (friend-add, etc.) that the local [Outbound Peering Gate](federation.md#outbound-peering-gate) intercepted because no `federation_peers` row exists yet for the target.
|
||||
|
||||
```
|
||||
GET /api/federation/approval-requests → PeerApprovalRequest[]
|
||||
POST /api/federation/approval-requests/:id/approve → { success: boolean }
|
||||
POST /api/federation/approval-requests/:id/deny → { success: boolean }
|
||||
GET /api/federation/approval-requests → { requests: ApprovalRequestSummary[] }
|
||||
POST /api/federation/approval-requests/:id/approve → { success, peerStatus?, peer? }
|
||||
POST /api/federation/approval-requests/:id/deny → { success }
|
||||
```
|
||||
|
||||
`PeerApprovalRequest` shape:
|
||||
`ApprovalRequestSummary` (see [api.md → Federation Peering Approval Queue](api.md#federation-peering-approval-queue) for the complete TypeScript shape):
|
||||
```typescript
|
||||
{
|
||||
id: string; // Snowflake
|
||||
origin: string; // Requesting instance URL
|
||||
id: string;
|
||||
direction: 'inbound' | 'outbound';
|
||||
origin: string;
|
||||
instanceName: string | null;
|
||||
requestedAt: number; // Epoch ms
|
||||
expiresAt: number; // Epoch ms (requestedAt + 30 days)
|
||||
requestedAt: number;
|
||||
expiresAt: number;
|
||||
// Outbound rows ONLY — inbound rows omit this field entirely (absent, not null and not []).
|
||||
subscribers?: Array<{ id, userId, username, triggerReason, triggerTarget, createdAt }>;
|
||||
}
|
||||
```
|
||||
|
||||
See [federation.md](federation.md) — Peer Approval Queue section for the full approval/denial/expiry flow.
|
||||
See [federation.md](federation.md) — Peer Approval Queue and Outbound Peering Gate sections for the full approval/denial/expiry flow (including the direction-branched approve/deny semantics, `onPeerActivated` cleanup invariant, and the inbound-expiry `/peer/denied` notification preserved unchanged).
|
||||
|
||||
---
|
||||
|
||||
@@ -431,9 +436,13 @@ Manages: instance name, registration toggle, discovery toggle, GIF API key, fede
|
||||
|
||||
#### FederationPanel
|
||||
|
||||
Manages: federation peers list, pending approval requests, manual peering initiation, secret rotation, peer reset.
|
||||
Manages: federation peers list, pending approval requests (inbound + outbound), manual peering initiation, secret rotation, peer reset.
|
||||
|
||||
- **Pending Approvals section:** Visible only when `pendingApprovalCount > 0` (from ready payload). Positioned above the peer list. Each row shows the requesting instance name and origin with Approve and Deny buttons. Approve calls `api.federation.approveApprovalRequest(id)` and Deny calls `api.federation.denyApprovalRequest(id)`; both remove the row from the local list on success.
|
||||
- **Pending Approvals section:** Visible only when `pendingApprovalCount > 0` (from ready payload — the count sums inbound + outbound rows). Positioned above the peer list. Both directions render as rows in the same unified queue, branched on `direction`:
|
||||
- **Inbound rows** — "{instanceName} ({origin}) — wants to peer with us." Approve / Deny buttons.
|
||||
- **Outbound rows** — "{instanceName} ({origin}) — N user(s) want us to peer with them. Triggered by: friend-add (etc.)." Inline expansion reveals the subscriber list (`username — friend_add → alice@orbit`, ...). Approve / Deny buttons same as inbound; backend branches on direction.
|
||||
- Approve calls `api.federation.approveApprovalRequest(id)` and Deny calls `api.federation.denyApprovalRequest(id)`. Both remove the row from the local list on success and refresh on `federation_approval_request_received` (which now also fires for outbound queue creation) and `federation_peers_changed`.
|
||||
- **ConfirmDialog copy variants:** the dialog branches on direction. Outbound approve confirms "send `/peer/accept` to {origin} on behalf of N user(s)"; outbound deny confirms "fan out denied notifications to N user(s) and discard the queued request" (no remote network call).
|
||||
- Federation peers: fetched via `api.federation.peers()`, displayed as a list with status badges (active/pending/unreachable/awaiting_approval/rejected/needs_attention), last-seen/synced times, and per-peer actions.
|
||||
- Peers with status `'revoked'` are filtered out of the visible list.
|
||||
- Revoke calls `api.federation.revokePeer(peerId)` and removes from local list.
|
||||
|
||||
@@ -142,6 +142,7 @@ GET /social/search ?q= → { users[] }
|
||||
| 404 | `user_not_found` | Remote lookup returned 404 (no such user, or tombstoned) |
|
||||
| 409 | `already_friends` | Friendship row already exists |
|
||||
| 409 | `peer_pending_approval` | Remote admin needs to approve the peering relationship |
|
||||
| 409 | `peer_pending_local_admin` | Local instance has `autoAcceptPeering=0` and the user attempted to friend-add a never-peered remote target. The user's own admin must approve before any traffic reaches the wire. Distinct from `peer_pending_approval` (remote admin must approve). See [federation.md → Outbound Peering Gate](federation.md#outbound-peering-gate). |
|
||||
| 409 | `peer_pending` | Peer handshake in flight |
|
||||
| 409 | `incoming_request_exists` | Opposite-direction pending request exists; response includes `requestId` for deep-link |
|
||||
| 429 | `lookup_rate_limited` | Remote `/users/lookup` returned 429; `Retry-After` header forwarded |
|
||||
@@ -229,6 +230,104 @@ POST /federation/users/lookup (HMAC-signed S2S, rate-limited 60/min/peer)
|
||||
|
||||
**`POST /api/federation/users/lookup`** — HMAC-authenticated S2S endpoint. Resolves a username on this instance to its canonical `(homeUserId, profile snapshot)`. Used by the cross-instance friend-add flow on the sender's home server before queuing a `friend_request_create` event. Responds to native, non-deleted users only; ignores `discoverable`. Returns `{ found: false, code: 'user_not_found' }` for stubs, tombstoned users, or unknown handles. See `federation.md` §1 "S2S User Lookup" for the full contract.
|
||||
|
||||
### Federation Peering Approval Queue
|
||||
|
||||
Inbound + outbound peering approval queue (`autoAcceptPeering=0`). See [federation.md → Peer Approval Queue](federation.md#peer-approval-queue) and [federation.md → Outbound Peering Gate](federation.md#outbound-peering-gate).
|
||||
|
||||
```
|
||||
GET /federation/approval-requests (admin) → { requests: ApprovalRequestSummary[] }
|
||||
POST /federation/approval-requests/:id/approve (admin) → { success, peerStatus?, peer? }
|
||||
POST /federation/approval-requests/:id/deny (admin) → { success }
|
||||
```
|
||||
|
||||
**`ApprovalRequestSummary` shape:**
|
||||
|
||||
```typescript
|
||||
type ApprovalRequestSummary = {
|
||||
id: string;
|
||||
direction: 'inbound' | 'outbound';
|
||||
origin: string;
|
||||
instanceName: string | null;
|
||||
requestedAt: number;
|
||||
expiresAt: number;
|
||||
// Outbound rows ONLY — inbound rows OMIT this field entirely (it is absent, not null and not []).
|
||||
subscribers?: ApprovalRequestSubscriberSummary[];
|
||||
};
|
||||
|
||||
type ApprovalRequestSubscriberSummary = {
|
||||
id: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
triggerReason: 'friend_add' | 'space_join' | 'direct_message';
|
||||
triggerTarget: string;
|
||||
createdAt: number;
|
||||
};
|
||||
```
|
||||
|
||||
**`POST /approval-requests/:id/approve`** — direction-branched.
|
||||
- **Inbound** — existing behavior preserved verbatim (creates / upserts a local `federation_peers` row with status `pending`, sends `/peer/accept` to origin forwarding the stored `approvalToken`, deletes the queue row regardless of whether the result is `active` (200) or `awaiting_approval` (202)).
|
||||
- **Outbound** — generates a fresh HMAC, sends `/peer/accept` to the origin (no token; we are the initiator).
|
||||
- On 200 → peer becomes `active`. `onPeerActivated` runs: fans out `kind='approved'` notifications to subscribers and cascade-deletes the queue row. The handler does NOT duplicate this cleanup.
|
||||
- On 202 → peer transitions to `awaiting_approval`, captures the returned `approvalToken`, and the queue row + subscribers are LEFT INTACT for the eventual remote-admin approval. `onPeerActivated` is NOT called yet.
|
||||
- On 4xx/5xx/network → the peer row is cleaned up; the queue row is LEFT INTACT so the admin can retry. Response status mirrors the wire failure (`502`/`503`/`504`).
|
||||
- **Response body:** `{ success, peerStatus?: 'active' | 'awaiting_approval', peer? }` for outbound; `{ success }` for inbound.
|
||||
|
||||
**`POST /approval-requests/:id/deny`** — direction-branched.
|
||||
- **Inbound** — existing behavior preserved (sends signed `/peer/denied` to origin, upserts a local `rejected` `federation_peers` row, deletes the queue row).
|
||||
- **Outbound** — fans out `kind='denied'` notifications to all `peer_approval_subscribers` of the queue row, then cascade-deletes the parent (no remote network call). Broadcasts `federation_peers_changed` to admins so the queue UI refreshes.
|
||||
|
||||
### Federation Peering Subscriptions (user-facing)
|
||||
|
||||
```
|
||||
GET /federation/peering-subscriptions (auth) → { subscriptions: PeeringSubscriptionSummary[] }
|
||||
DELETE /federation/peering-subscriptions/:id (auth) → { success }
|
||||
```
|
||||
|
||||
User-facing surface for the rows in `peer_approval_subscribers` belonging to the calling user. GET joins the parent `peer_approval_requests` row to include peer origin/instance metadata.
|
||||
|
||||
```typescript
|
||||
type PeeringSubscriptionSummary = {
|
||||
id: string;
|
||||
requestId: string;
|
||||
peerOrigin: string;
|
||||
peerInstanceName: string | null;
|
||||
triggerReason: 'friend_add' | 'space_join' | 'direct_message';
|
||||
triggerTarget: string;
|
||||
createdAt: number;
|
||||
};
|
||||
```
|
||||
|
||||
**`DELETE /peering-subscriptions/:id`:**
|
||||
- 404 if the subscriber row doesn't exist.
|
||||
- 403 if the row belongs to a different user.
|
||||
- On success: deletes the subscriber row; if it was the last subscriber for the parent, cascade-deletes the parent (admin's queue row disappears too). No `peer_approval_notifications` row is created (the user took the action; they know).
|
||||
- Broadcasts `peering_subscription_changed` to the calling user (multi-tab refresh) and `federation_peers_changed` to admins if the parent was deleted.
|
||||
|
||||
### Federation Peering Notifications (user-facing)
|
||||
|
||||
```
|
||||
GET /federation/peering-notifications (auth) ?unread=1? → { notifications: PeeringNotificationSummary[] }
|
||||
POST /federation/peering-notifications/:id/read (auth) → { success }
|
||||
POST /federation/peering-notifications/read-all (auth) → { success, count }
|
||||
```
|
||||
|
||||
User-facing terminal-state notifications for peering events. GET orders DESC by `createdAt`; `?unread=1` filters to `readAt IS NULL`.
|
||||
|
||||
```typescript
|
||||
type PeeringNotificationSummary = {
|
||||
id: string;
|
||||
kind: 'approved' | 'denied' | 'expired';
|
||||
peerOrigin: string;
|
||||
triggerReason: 'friend_add' | 'space_join' | 'direct_message';
|
||||
triggerTarget: string;
|
||||
createdAt: number;
|
||||
readAt: number | null;
|
||||
};
|
||||
```
|
||||
|
||||
**`POST /:id/read`** — sets `readAt = Date.now()` for the calling user's notification (404 / 403 on miss / mismatch).
|
||||
**`POST /read-all`** — marks all of the calling user's unread notifications as read; returns `{ success, count }` where `count` is the number of rows updated.
|
||||
|
||||
## Utilities (`routes/utils.ts`) — auth required
|
||||
```
|
||||
GET /utils/metadata ?url= → { title?, description?, image?, siteName? }
|
||||
|
||||
@@ -340,7 +340,58 @@ Client-driven LWW whole-registry push (same pattern as `profileSync.ts`):
|
||||
|
||||
---
|
||||
|
||||
## 8. Relationship to S2S Federation
|
||||
## 8. Outbound Peering Gate (client surfaces)
|
||||
|
||||
When the local instance has `autoAcceptPeering=0`, every outbound new-peer attempt funnels through the centralized [Outbound Peering Gate](federation.md#outbound-peering-gate) on the server. The client surfaces three things: a new error code on the friend-add path, a new peering-status value on `/peer/ensure`, and two new Connections-settings panels (pending and outcomes).
|
||||
|
||||
### Peering-status taxonomy (`/peer/ensure` response)
|
||||
|
||||
`peeringStatus` returned from `POST /api/federation/peer/ensure` now includes `'admin_required'` alongside the existing `'active' | 'pending' | 'awaiting_approval' | 'rejected' | 'unreachable' | 'revoked'`. `'admin_required'` means: gate fired locally, your own admin must approve before any traffic reaches the wire. The user's request becomes admin-approvable rather than auto-firing.
|
||||
|
||||
### Friend-add error mapping (`peer_pending_local_admin`)
|
||||
|
||||
`POST /api/social/requests` returns 409 `peer_pending_local_admin` when the gate fires for a never-peered remote target (the user's request is queued + subscriber-tracked on the server; admin must approve).
|
||||
|
||||
`packages/web/src/utils/friendErrors.ts` maps `peer_pending_local_admin` to:
|
||||
|
||||
> "Your admin needs to approve federation with this instance. You'll see your request in Connections settings."
|
||||
|
||||
The catch handler reads `err.message` (per the API client error contract documented in §1) and passes it to `mapServerErrorToMessage`. Distinct from `peer_pending_approval` ("the *remote* admin must approve") — this one is local-admin gating.
|
||||
|
||||
### Connections settings — Pending peering approvals
|
||||
|
||||
A new section in the Connections settings UI (alongside the federation registry) lists the calling user's rows from `peer_approval_subscribers`, joined to parent `peer_approval_requests`. Each row renders:
|
||||
|
||||
- "Awaiting your admin's approval to federate with `{peerOrigin}` so you can `friend_add → alice@orbit`."
|
||||
- A Cancel button. Cancel calls `DELETE /api/federation/peering-subscriptions/:id`. If the cancelled row was the last subscriber for the parent, the parent cascades and disappears from the admin's queue too.
|
||||
|
||||
Live updates: a `peering_subscription_changed` WebSocket event refetches the list.
|
||||
|
||||
### Connections settings — Recent peering outcomes
|
||||
|
||||
A second new section above the pending list shows unread `peer_approval_notifications` rows ordered by `createdAt DESC`. Each row's copy branches on `kind`:
|
||||
|
||||
- **`approved`** — "Your peering request to `{peerOrigin}` was approved — retry your friend-add to `{triggerTarget}`?" `[Retry]` `[Dismiss]`. Retry deep-links to the friend-add UI prefilled with the original target. Today only the `friend_add` reason produces a retry deep-link; future trigger reasons add their own deep-link flows. Dismiss POSTs to `/peering-notifications/:id/read`.
|
||||
- **`denied`** — "Your peering request to `{peerOrigin}` was denied by your admin." `[Dismiss]`.
|
||||
- **`expired`** — "Your peering request to `{peerOrigin}` expired without admin action." `[Dismiss]`.
|
||||
|
||||
A "Mark all as read" action POSTs to `/peering-notifications/read-all`. Read rows hide from view (soft-delete preserves audit; the storage janitor cleans up read rows older than 30 days).
|
||||
|
||||
Live updates: a `peering_notification_received` WebSocket event refetches the list and may surface a transient toast for the matching `kind` (online users only).
|
||||
|
||||
### Federation store slice
|
||||
|
||||
A new `federationStore.ts` slice (separate from `instanceStore`) holds:
|
||||
|
||||
- `peeringSubscriptions: PeeringSubscriptionSummary[]`
|
||||
- `peeringNotifications: PeeringNotificationSummary[]`
|
||||
- `pendingFriendAddPrefill?: { username: string }` — side-channel populated by the Retry button on `kind='approved'` notifications, consumed by the friend-add modal on next open.
|
||||
|
||||
This slice is intentionally separate from `instanceStore` because the data is per-user (not per-instance) and lives on the home server only. WebSocket handlers route `peering_subscription_changed` and `peering_notification_received` events into this slice's refetch actions.
|
||||
|
||||
---
|
||||
|
||||
## 9. Relationship to S2S Federation
|
||||
|
||||
Client-side and S2S federation serve different purposes:
|
||||
|
||||
|
||||
@@ -366,17 +366,62 @@ PK: (spaceId, userId, restrictionType)
|
||||
| approvalToken | text | | Single-use 64-hex-char token stored when this row is in `awaiting_approval` (received from remote's 202 response). Verified against the inbound `/peer/accept` `approvalToken` field before promoting to `active`. Cleared (`NULL`) on promotion. See [federation.md → Approval Token Verification](federation.md#approval-token-verification). |
|
||||
|
||||
### peer_approval_requests
|
||||
Holds incoming peering requests queued for admin review when `autoAcceptPeering` is `false`. One row per requesting origin (UNIQUE constraint). Rows expire after 30 days via janitor cleanup.
|
||||
Queue of peering requests pending admin review when `autoAcceptPeering` is `false`. Holds **both directions**: inbound rows (remote asked to peer with us) and outbound rows (a local user-initiated `ensurePeered` call gated on this side; see [federation.md → Outbound Peering Gate](federation.md#outbound-peering-gate)). UNIQUE on `(origin, direction)` so the same origin may have at most one row per direction simultaneously. Rows expire after 30 days via janitor cleanup.
|
||||
|
||||
| Column | Type | Default | Notes |
|
||||
|--------|------|---------|-------|
|
||||
| id | text PK | | Snowflake |
|
||||
| origin | text NOT NULL UNIQUE | | Requesting instance's origin URL |
|
||||
| instanceName | text | | Instance name sent by requester |
|
||||
| hmacSecret | text NOT NULL | | Requester's HMAC secret; used to sign denial notification |
|
||||
| origin | text NOT NULL | | Requesting / target instance's origin URL. UNIQUE per `direction` (composite UNIQUE `(origin, direction)`). |
|
||||
| direction | text NOT NULL | `'inbound'` | `'inbound'` (remote → us) or `'outbound'` (us → remote, gate-created on user_action). Migration backfills existing rows to `'inbound'`. |
|
||||
| instanceName | text | | Instance name (sent by requester for inbound; null for outbound until populated by future enrichment). |
|
||||
| hmacSecret | text | | Requester's HMAC secret for inbound (used to sign the `/peer/denied` notification). **Nullable** — outbound rows have `hmac_secret = NULL` and the `/approve` handler generates fresh HMAC at the moment it sends `/peer/accept`. CHECK enforced (see below). |
|
||||
| requestedAt | integer NOT NULL | | Epoch ms |
|
||||
| expiresAt | integer NOT NULL | | Epoch ms; requestedAt + 30 days |
|
||||
| approvalToken | text | | Single-use 64-hex-char token issued in the 202 response when this row is created. Forwarded by `/approve` in its outbound `/peer/accept` so the remote initiator can verify mutual admin approval. Deleted along with this row when `/approve` runs. See [federation.md → Approval Token Verification](federation.md#approval-token-verification). |
|
||||
| approvalToken | text | | Single-use 64-hex-char token issued by the receiver in the 202 response when an inbound row is created. Forwarded by `/approve` in its outbound `/peer/accept` so the remote initiator can verify mutual admin approval. Deleted along with this row when `/approve` runs. Inbound-only meaning preserved (outbound rows always have `approval_token = NULL`). See [federation.md → Approval Token Verification](federation.md#approval-token-verification). |
|
||||
|
||||
**CHECK constraint** (direction-specific shape):
|
||||
|
||||
```sql
|
||||
CHECK (
|
||||
(direction = 'inbound' AND hmac_secret IS NOT NULL)
|
||||
OR (direction = 'outbound')
|
||||
)
|
||||
```
|
||||
|
||||
> **drizzle-kit limitation:** drizzle-kit does NOT represent SQLite CHECK constraints in its snapshot/diff. The constraint is created by the original baseline migration (or, for the post-baseline ALTER, hand-written SQL) and is preserved in `schema.ts` as a comment so a future table recreate (drizzle generates a recreate when UNIQUE/PK changes again) re-adds the CHECK by hand. If you regenerate a migration that recreates this table, audit the generated SQL and re-add the CHECK clause manually before applying.
|
||||
|
||||
### peer_approval_subscribers
|
||||
Per-user "I want this peering relationship" subscriber rows attached to outbound `peer_approval_requests`. Logically outbound-only (inbound rows have no subscribers). Existence = waiting; deletion = resolved (resolution recorded in `peer_approval_notifications` at deletion time, except for the canceller path). One subscriber may have multiple rows on the same parent if they triggered the gate from different actions/targets.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | text PK | Snowflake |
|
||||
| requestId | text NOT NULL | FK → peer_approval_requests.id CASCADE — parent deletion (admin approve→active fanout, admin deny, last-subscriber cancel, expiry) automatically clears subscriber rows. |
|
||||
| userId | text NOT NULL | FK → users.id CASCADE |
|
||||
| triggerReason | text NOT NULL | `'friend_add'` \| `'space_join'` \| `'direct_message'` (`PeeringTriggerReason` enum in `packages/shared/src/types.ts`). |
|
||||
| triggerTarget | text NOT NULL | Action target — for `friend_add` this is `username@instance`; for `space_join` an invite code or space ID; for `direct_message` a recipient handle. Never stores message bodies, attachments, or user content. |
|
||||
| createdAt | integer NOT NULL | Epoch ms |
|
||||
|
||||
**UNIQUE:** `(request_id, user_id, trigger_reason, trigger_target)` — same user retriggering the gate with the same reason+target updates rather than duplicates.
|
||||
**Index:** `idx_peer_approval_subscribers_user_id` on `(user_id)` — supports the user-facing pending list query.
|
||||
|
||||
### peer_approval_notifications
|
||||
Terminal-state notifications for peering events (approved / denied / expired). Scoped to peering — NOT a generalized in-app notification system. When a generalized system is built later, this table either migrates into it or stays as a peering-specific artifact (decision deferred to that spec).
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | text PK | Snowflake |
|
||||
| userId | text NOT NULL | FK → users.id CASCADE |
|
||||
| kind | text NOT NULL | `'approved'` \| `'denied'` \| `'expired'`. |
|
||||
| peerOrigin | text NOT NULL | Origin URL the notification refers to. |
|
||||
| triggerReason | text NOT NULL | Mirrors the originating subscriber row's `trigger_reason`. |
|
||||
| triggerTarget | text NOT NULL | Mirrors the originating subscriber row's `trigger_target`. |
|
||||
| createdAt | integer NOT NULL | Epoch ms |
|
||||
| readAt | integer | Nullable. NULL = unread; epoch ms once dismissed/marked read. |
|
||||
|
||||
**Index:** `idx_peer_approval_notifications_user_id` on `(user_id)` — supports the user-facing list and unread-filter queries.
|
||||
|
||||
Inserted by `onPeerActivated` (`'approved'`), the outbound `/deny` handler (`'denied'`), and the storage janitor outbound expiry pass (`'expired'`). Read rows older than 30 days are auto-cleaned by the janitor; unread rows are never auto-cleaned.
|
||||
|
||||
### federation_outbox
|
||||
UNIQUE: (peerId, entityId)
|
||||
|
||||
+94
-10
@@ -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`
|
||||
- `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`:
|
||||
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
|
||||
**Approval flow** — Admin approves via `POST /api/federation/approval-requests/:id/approve`. The handler dispatches on `peer_approval_requests.direction`:
|
||||
|
||||
**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)
|
||||
2. Receiving instance transitions its local peer record from `awaiting_approval` → `rejected`
|
||||
3. A local `federationPeer` record is upserted with status `rejected` to block future unsolicited requests from the same origin
|
||||
4. The `peer_approval_requests` row is deleted
|
||||
|
||||
*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.
|
||||
|
||||
**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
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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
|
||||
|
||||
@@ -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/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/approval-requests` | GET | JWT + admin | List pending peering approval requests |
|
||||
| `/api/federation/approval-requests/:id/approve` | POST | JWT + admin | Approve request, initiate handshake |
|
||||
| `/api/federation/approval-requests/:id/deny` | POST | JWT + admin | Deny request, notify requester |
|
||||
| `/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 — direction-branched (see Approval flow above) |
|
||||
| `/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`.
|
||||
|
||||
|
||||
@@ -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`
|
||||
- `'rejected'` → 403 `peer_rejected`
|
||||
- `'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:
|
||||
- `not_found` → 404 `user_not_found`
|
||||
- `unreachable` → 503 `peer_unreachable`
|
||||
|
||||
@@ -192,6 +192,9 @@ reason: `'displaced'` (new tab) | `'session_closed'`
|
||||
| type | fields | scope |
|
||||
|------|--------|-------|
|
||||
| `federation_file_rejected` | messageId, dmChannelId, attachmentId, affectedUsers[] | DM members |
|
||||
| `federation_approval_request_received` | — (refetch trigger; payload: `{ type }`) | admins. Fires for **both** inbound peering requests (remote → us) AND outbound queue creation when the [Outbound Peering Gate](federation.md#outbound-peering-gate) creates a `peer_approval_requests` row in response to a user_action. Payload shape unchanged from the inbound-only behavior; only the firing surface widened. |
|
||||
| `peering_subscription_changed` | — (refetch trigger; payload: `{ type }`) | the subscribing user (all of their connected sessions). Fires when a `peer_approval_subscribers` row belonging to the user is created, modified, or deleted (gate fan-in, user cancel, parent cascade). Client refetches `GET /api/federation/peering-subscriptions`. |
|
||||
| `peering_notification_received` | `{ type, kind: 'approved' \| 'denied' \| 'expired' }` | the user the notification belongs to. Fires when a `peer_approval_notifications` row is created (`onPeerActivated` outbound fanout, outbound `/deny` fanout, janitor outbound expiry). Client refetches `GET /api/federation/peering-notifications` and may surface a transient toast for online users. |
|
||||
|
||||
**S2S relay-only event (not a direct client WS event):**
|
||||
|
||||
|
||||
@@ -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,
|
||||
"tag": "0002_peer_approval_token",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1777229997210,
|
||||
"tag": "0003_brave_inhumans",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -377,15 +377,48 @@ export const federationPeers = sqliteTable('federation_peers', {
|
||||
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', {
|
||||
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'),
|
||||
hmacSecret: text('hmac_secret').notNull(),
|
||||
hmacSecret: text('hmac_secret'),
|
||||
requestedAt: integer('requested_at').notNull(),
|
||||
expiresAt: integer('expires_at').notNull(),
|
||||
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', {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ import { tombstoneUser, collectDeletionBroadcastTargets, collectProfileBroadcast
|
||||
import { computeFederatedId, getDmParticipants, sendCallRelay } from '../utils/federationOutbox.js';
|
||||
import { onPeerActivated, onPeerDeactivated } from '../utils/federationPeerActivation.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). */
|
||||
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> {
|
||||
// ─── POST /api/federation/peer/initiate ────────────────────────────────────
|
||||
// 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 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
|
||||
// peeringStatus values. The mapping:
|
||||
// 'active' → 'active' (peer is live)
|
||||
// 'rejected' → 'rejected' (permanently blocked)
|
||||
// 'pending' → 'awaiting_approval' (queued on remote, waiting for admin)
|
||||
// 'failed' → 'pending' (transient error, will retry automatically)
|
||||
// 'active' → 'active' (peer is live)
|
||||
// 'rejected' → 'rejected' (permanently blocked)
|
||||
// 'pending' → 'awaiting_approval' (queued on remote, waiting for admin)
|
||||
// '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",
|
||||
// while 'failed' means "network/timeout — the outbox worker will retry next tick".
|
||||
// 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 });
|
||||
case 'failed':
|
||||
return reply.code(200).send({ peeringStatus: 'pending', error: result.error });
|
||||
case 'admin_required':
|
||||
return reply.code(200).send({ peeringStatus: 'admin_required' });
|
||||
default:
|
||||
return reply.code(200).send({ peeringStatus: 'pending', error: 'Unknown peering result' });
|
||||
}
|
||||
@@ -1161,6 +1656,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
||||
.select({
|
||||
id: schema.peerApprovalRequests.id,
|
||||
origin: schema.peerApprovalRequests.origin,
|
||||
direction: schema.peerApprovalRequests.direction,
|
||||
instanceName: schema.peerApprovalRequests.instanceName,
|
||||
requestedAt: schema.peerApprovalRequests.requestedAt,
|
||||
expiresAt: schema.peerApprovalRequests.expiresAt,
|
||||
@@ -1169,11 +1665,50 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
||||
.orderBy(desc(schema.peerApprovalRequests.requestedAt))
|
||||
.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 ───────────────────
|
||||
// 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 } }>(
|
||||
'/api/federation/approval-requests/:id/approve',
|
||||
{ preHandler: [authenticate, requireAdmin] },
|
||||
@@ -1201,158 +1736,18 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
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 (approvalReq.direction === 'outbound') {
|
||||
return await handleOutboundApprove(approvalReq, localOrigin, reply);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
return await handleInboundApprove(approvalReq, localOrigin, reply);
|
||||
},
|
||||
);
|
||||
|
||||
// ─── 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 } }>(
|
||||
'/api/federation/approval-requests/:id/deny',
|
||||
{ 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 });
|
||||
}
|
||||
|
||||
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 (approvalReq.direction === 'outbound') {
|
||||
return await handleOutboundDeny(approvalReq, reply);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
return await handleInboundDeny(approvalReq, reply);
|
||||
},
|
||||
);
|
||||
|
||||
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()
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.origin, approvalReq.origin))
|
||||
.from(schema.peerApprovalSubscribers)
|
||||
.where(eq(schema.peerApprovalSubscribers.id, id))
|
||||
.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();
|
||||
if (!sub) {
|
||||
return reply.code(404).send({ error: 'subscription_not_found', statusCode: 404 });
|
||||
}
|
||||
if (sub.userId !== userId) {
|
||||
return reply.code(403).send({ error: 'forbidden', statusCode: 403 });
|
||||
}
|
||||
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
|
||||
db.delete(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.id, id))
|
||||
db.delete(schema.peerApprovalSubscribers)
|
||||
.where(eq(schema.peerApprovalSubscribers.id, id))
|
||||
.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 });
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -178,7 +178,12 @@ async function handleFederatedFriendRequest(
|
||||
}
|
||||
|
||||
// 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') {
|
||||
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 });
|
||||
}
|
||||
if (peering.status === 'admin_required') {
|
||||
return reply.code(409).send({
|
||||
error: 'peer_pending_local_admin',
|
||||
statusCode: 409,
|
||||
domain: targetDomain,
|
||||
});
|
||||
}
|
||||
// peering.status === 'active' — continue
|
||||
|
||||
// 3. Lookup
|
||||
|
||||
@@ -520,6 +520,7 @@ export const CALL_PEERING_TIMEOUT_MS = 3_000;
|
||||
export type CallRelayFailureReason =
|
||||
| 'peer_rejected'
|
||||
| 'peer_awaiting_approval'
|
||||
| 'peer_admin_required'
|
||||
| 'peer_transient_failure'
|
||||
| 'post_failed';
|
||||
|
||||
@@ -547,6 +548,7 @@ export function mapCallReasonToEventReason(reason: CallRelayFailureReason): DmCa
|
||||
switch (reason) {
|
||||
case 'peer_rejected': return 'peer_rejected';
|
||||
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 'post_failed': return 'peer_transient_failure'; // 4xx looks transient to users
|
||||
}
|
||||
@@ -583,14 +585,14 @@ export async function sendCallRelay(
|
||||
if (!peer) {
|
||||
// ─── Non-blocking mode (typing): warm up in background, do not POST ──
|
||||
if (timeoutMs === 0) {
|
||||
ensurePeered(targetPeerOrigin).catch(err => {
|
||||
ensurePeered(targetPeerOrigin, { kind: 'system' }).catch(err => {
|
||||
console.warn('[federation] typing-triggered background handshake:', targetPeerOrigin, err);
|
||||
});
|
||||
return { ok: false, reason: 'peer_transient_failure', error: 'peer not active' };
|
||||
}
|
||||
|
||||
// ─── Race ensurePeered against the deadline ──
|
||||
const raced = await racePeering(targetPeerOrigin, timeoutMs);
|
||||
const raced = await racePeering(targetPeerOrigin, timeoutMs, { kind: 'system' });
|
||||
|
||||
switch (raced.status) {
|
||||
case 'active':
|
||||
@@ -607,6 +609,8 @@ export async function sendCallRelay(
|
||||
return { ok: false, reason: 'peer_rejected', error: raced.error };
|
||||
case 'pending':
|
||||
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':
|
||||
return { ok: false, reason: 'peer_transient_failure', error: raced.error };
|
||||
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 { isFederationRelayEnabled } from './federationOutbox.js';
|
||||
import { buildFederationHeaders, getOurOrigin } from './federationAuth.js';
|
||||
import { generateSnowflake } from './snowflake.js';
|
||||
import type { FederationRelayEvent } from '@backspace/shared';
|
||||
|
||||
export type PeerActivationReason =
|
||||
@@ -50,6 +51,7 @@ export async function onPeerActivated(
|
||||
try {
|
||||
resetOutboxBackoff(peerId);
|
||||
await syncPeerMutationLog(peerId, reason);
|
||||
await fanoutOutboundSubscribers(peerId);
|
||||
const { connectionManager } = await import('../ws/handler.js');
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
} 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)
|
||||
* and run onPeerActivated for each. Replaces runInitialSyncForNewPeers.
|
||||
|
||||
@@ -101,7 +101,7 @@ describe('performHandshake — approval token capture & clear', () => {
|
||||
);
|
||||
|
||||
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');
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('performHandshake — approval token capture & clear', () => {
|
||||
);
|
||||
|
||||
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');
|
||||
const peer = testDb.select().from(schema.federationPeers)
|
||||
@@ -135,7 +135,7 @@ describe('performHandshake — approval token capture & clear', () => {
|
||||
);
|
||||
|
||||
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');
|
||||
const peer = testDb.select().from(schema.federationPeers)
|
||||
@@ -161,7 +161,7 @@ describe('performHandshake — approval token capture & clear', () => {
|
||||
);
|
||||
|
||||
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');
|
||||
|
||||
const peer = testDb.select().from(schema.federationPeers)
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('performHandshake — persist remote instanceName', () => {
|
||||
|
||||
const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js');
|
||||
_clearInFlightPeering();
|
||||
const result = await ensurePeered('https://remote.example');
|
||||
const result = await ensurePeered('https://remote.example', { kind: 'system' });
|
||||
|
||||
expect(result.status).toBe('active');
|
||||
const row = testDb.select().from(schema.federationPeers)
|
||||
@@ -117,7 +117,7 @@ describe('performHandshake — persist remote instanceName', () => {
|
||||
|
||||
const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js');
|
||||
_clearInFlightPeering();
|
||||
const result = await ensurePeered('https://remote.example');
|
||||
const result = await ensurePeered('https://remote.example', { kind: 'system' });
|
||||
|
||||
expect(result.status).toBe('active');
|
||||
const row = testDb.select().from(schema.federationPeers)
|
||||
@@ -136,7 +136,7 @@ describe('performHandshake — persist remote instanceName', () => {
|
||||
|
||||
const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js');
|
||||
_clearInFlightPeering();
|
||||
const result = await ensurePeered('https://remote.example');
|
||||
const result = await ensurePeered('https://remote.example', { kind: 'system' });
|
||||
|
||||
expect(result.status).toBe('active');
|
||||
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',
|
||||
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(stub).toHaveBeenCalledWith('https://example.com');
|
||||
expect(stub).toHaveBeenCalledWith('https://example.com', { kind: 'system' });
|
||||
});
|
||||
|
||||
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(() => {
|
||||
// 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);
|
||||
const result = await racePromise;
|
||||
expect(result).toEqual({ status: 'timeout' });
|
||||
@@ -73,7 +73,7 @@ describe('racePeering', () => {
|
||||
status: 'rejected',
|
||||
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' });
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ describe('racePeering', () => {
|
||||
const stub = vi.fn(() => new Promise<EnsurePeeredResult>((_, reject) => {
|
||||
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);
|
||||
const result = await racePromise;
|
||||
expect(result).toEqual({ status: 'timeout' });
|
||||
@@ -104,7 +104,7 @@ describe('racePeering', () => {
|
||||
const stub = vi.fn(async (): Promise<EnsurePeeredResult> => {
|
||||
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' });
|
||||
// The handshake rejection was the race winner — no background warn should fire.
|
||||
await Promise.resolve();
|
||||
@@ -156,7 +156,7 @@ describe('ensurePeered needs_attention handling', () => {
|
||||
const { ensurePeered } = await import('./federationPeering.js');
|
||||
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');
|
||||
if (result.status === 'rejected') {
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('ensurePeered — refuses when unresolved inbound approval-request exis
|
||||
|
||||
const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js');
|
||||
_clearInFlightPeering();
|
||||
const result = await ensurePeered('https://orbit.test');
|
||||
const result = await ensurePeered('https://orbit.test', { kind: 'system' });
|
||||
|
||||
expect(result.status).toBe('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');
|
||||
_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
|
||||
// the pre-handshake 'rejected' from the new guard.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { getDb } from '../db/index.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 { getOurOrigin, generateHmacSecret } from './federationAuth.js';
|
||||
import { validateOrigin } from '../routes/federation.js';
|
||||
import { onPeerActivated, onPeerDeactivated } from './federationPeerActivation.js';
|
||||
import type { EnsurePeeredCallerIntent } from '@backspace/shared';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -12,7 +13,8 @@ export type EnsurePeeredResult =
|
||||
| { status: 'active'; peerId: string }
|
||||
| { status: 'rejected'; error: string }
|
||||
| { status: 'failed'; error: string }
|
||||
| { status: 'pending'; error: string };
|
||||
| { status: 'pending'; error: string }
|
||||
| { status: 'admin_required'; error: string };
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -26,6 +28,106 @@ function getInstanceName(): string | 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 ─────────────────────────────────────────────────
|
||||
|
||||
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: '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
|
||||
const normalized = validateOrigin(origin);
|
||||
if (!normalized) {
|
||||
@@ -93,10 +198,20 @@ export async function ensurePeered(origin: string): Promise<EnsurePeeredResult>
|
||||
// The legitimate approval flow (routes/federation.ts /approval-requests/:id/
|
||||
// approve) does NOT call ensurePeered — it deletes the approval-request first
|
||||
// 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
|
||||
.select({ id: schema.peerApprovalRequests.id })
|
||||
.from(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.origin, normalized))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.peerApprovalRequests.origin, normalized),
|
||||
eq(schema.peerApprovalRequests.direction, 'inbound'),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
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
|
||||
const inflight = inFlightPeering.get(normalized);
|
||||
if (inflight) {
|
||||
@@ -280,9 +423,13 @@ export function _clearInFlightPeering(): void {
|
||||
export async function racePeering(
|
||||
origin: string,
|
||||
timeoutMs: number,
|
||||
ensurePeeredFn: (origin: string) => Promise<EnsurePeeredResult> = ensurePeered,
|
||||
intent: EnsurePeeredCallerIntent,
|
||||
ensurePeeredFn: (
|
||||
origin: string,
|
||||
intent: EnsurePeeredCallerIntent,
|
||||
) => Promise<EnsurePeeredResult> = ensurePeered,
|
||||
): Promise<EnsurePeeredResult | { status: 'timeout' }> {
|
||||
const handshake = ensurePeeredFn(origin);
|
||||
const handshake = ensurePeeredFn(origin, intent);
|
||||
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<{ status: 'timeout' }>(resolve => {
|
||||
|
||||
@@ -496,7 +496,7 @@ async function resolvePendingPeers(): Promise<void> {
|
||||
for (const { peerId, peerOrigin } of pendingWithEntries) {
|
||||
console.log(`[federation-worker] Attempting auto-peer with ${peerOrigin}...`);
|
||||
|
||||
const result = await ensurePeered(peerOrigin);
|
||||
const result = await ensurePeered(peerOrigin, { kind: 'system' });
|
||||
|
||||
switch (result.status) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { and, eq, inArray, isNotNull, isNull, lt, lte } from 'drizzle-orm';
|
||||
import { config } from '../config.js';
|
||||
import { getDb, getRawDb, schema } from '../db/index.js';
|
||||
import { deleteUploadFile, deleteAttachmentFiles } from './fileCleanup.js';
|
||||
import { generateSnowflake } from './snowflake.js';
|
||||
import type { StorageStats, StorageBreakdown, OrphanedFile, CleanupResult } from '@backspace/shared';
|
||||
|
||||
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.
|
||||
* For each expired request, attempt to send a signed denial notification
|
||||
* to the requesting instance before deleting. If notification fails,
|
||||
* leave the record for the next janitor cycle.
|
||||
*
|
||||
* Outbound rows: before deletion, fan out kind='expired' notifications to
|
||||
* 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> {
|
||||
const db = getDb();
|
||||
const expired = db
|
||||
const expiredRequests = db
|
||||
.select()
|
||||
.from(schema.peerApprovalRequests)
|
||||
.where(lte(schema.peerApprovalRequests.expiresAt, Date.now()))
|
||||
.all();
|
||||
|
||||
if (expired.length === 0) return 0;
|
||||
if (expiredRequests.length === 0) return 0;
|
||||
|
||||
const { getOurOrigin, buildFederationHeaders } = await import('./federationAuth.js');
|
||||
const { connectionManager } = await import('../ws/handler.js');
|
||||
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({
|
||||
origin: ourOrigin,
|
||||
reason: 'expired' as const,
|
||||
message: 'Request expired — no response from admin within 30 days',
|
||||
});
|
||||
|
||||
const headers = buildFederationHeaders(denialBody, req.hmacSecret, ourOrigin);
|
||||
|
||||
let sent = false;
|
||||
@@ -478,13 +540,43 @@ export async function cleanupExpiredApprovalRequests(): Promise<number> {
|
||||
db.delete(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.id, req.id))
|
||||
.run();
|
||||
cleaned++;
|
||||
deletedCount++;
|
||||
} 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
|
||||
* - Soft-deleted DM channels past grace period
|
||||
*/
|
||||
export function runFederationJanitor(): void {
|
||||
export async function runFederationJanitor(): Promise<void> {
|
||||
try {
|
||||
const outbox = cleanupFederationOutbox();
|
||||
const mutLog = cleanupFederationMutationLog();
|
||||
@@ -622,14 +714,21 @@ export function runFederationJanitor(): void {
|
||||
);
|
||||
}
|
||||
|
||||
// Async: expire approval requests (sends network notifications)
|
||||
cleanupExpiredApprovalRequests().then((approvalExpired) => {
|
||||
// Expire approval requests:
|
||||
// - 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) {
|
||||
console.log(`[storage-janitor] Expired ${approvalExpired} peer approval request(s)`);
|
||||
}
|
||||
}).catch((err) => {
|
||||
cleanupReadPeeringNotifications();
|
||||
} catch (err) {
|
||||
console.error('[storage-janitor] Approval request expiry error:', err);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[storage-janitor] Federation GC sweep error:', err);
|
||||
}
|
||||
|
||||
@@ -467,6 +467,8 @@ export type ServerEvent =
|
||||
| { type: 'federation_peer_active'; peerOrigin: string }
|
||||
| { type: 'federation_peers_changed' }
|
||||
| { 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: 'pong' }
|
||||
| { type: 'error'; message: string };
|
||||
@@ -1026,3 +1028,104 @@ export interface FederationPeer {
|
||||
rotationInProgress: boolean;
|
||||
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[];
|
||||
}
|
||||
|
||||
@@ -53,9 +53,12 @@ import type {
|
||||
FederationIdentityDeleteRequest,
|
||||
FederationIdentityDeleteResponse,
|
||||
FederationPeer,
|
||||
ApprovalRequest,
|
||||
PeeringSubscription,
|
||||
PeeringNotification,
|
||||
} from '@backspace/shared';
|
||||
|
||||
export type { FederationPeer };
|
||||
export type { FederationPeer, ApprovalRequest, PeeringSubscription, PeeringNotification };
|
||||
|
||||
export class RateLimitError extends Error {
|
||||
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 {
|
||||
readonly auth: {
|
||||
register: (data: RegisterRequest) => Promise<AuthResponse>;
|
||||
@@ -230,6 +225,11 @@ export class BackspaceApiClient {
|
||||
approvalRequests: () => Promise<{ requests: ApprovalRequest[] }>;
|
||||
approveRequest: (id: string) => Promise<{ success: boolean; peer?: FederationPeer }>;
|
||||
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: {
|
||||
@@ -697,6 +697,27 @@ export class BackspaceApiClient {
|
||||
request<{ success: boolean }>(
|
||||
'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 = {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { mapServerErrorToMessage } from '../../utils/friendErrors';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { useInstanceStore } from '../../stores/instanceStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useFederationStore } from '../../stores/federationStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { MemberListToggleButton } from '../layout/MemberListToggleButton';
|
||||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||
@@ -34,6 +35,17 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
||||
const navigate = useNavigate();
|
||||
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 {
|
||||
friends,
|
||||
requests,
|
||||
@@ -394,7 +406,13 @@ function AddFriendTab({
|
||||
const fetchDiscoverUsers = useDiscoverStore((s) => s.fetchUsers);
|
||||
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 [searchLoading, setSearchLoading] = 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 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 { useAuthStore } from '../../stores/authStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useFederationStore } from '../../stores/federationStore';
|
||||
import { isElectron } from '../../platform/platform';
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
export function ConnectedInstances() {
|
||||
@@ -974,6 +1287,17 @@ export function ConnectedInstances() {
|
||||
const registry = useInstanceStore((s) => s.registry);
|
||||
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 [filter, setFilter] = useState<StatusFilter>('all');
|
||||
const [sortBy, setSortBy] = useState<SortBy>('dateAdded');
|
||||
@@ -1025,7 +1349,15 @@ export function ConnectedInstances() {
|
||||
: null;
|
||||
|
||||
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">
|
||||
Connected Instances
|
||||
</div>
|
||||
@@ -1109,6 +1441,7 @@ export function ConnectedInstances() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -693,16 +693,44 @@ function PendingApprovals({ onCountChange }: { onCountChange?: (count: number) =
|
||||
<div className="text-xs text-txt-tertiary py-2">Loading...</div>
|
||||
)}
|
||||
{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 (
|
||||
<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="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 mt-0.5">
|
||||
Requested {formatRelativeTime(req.requestedAt)}
|
||||
</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 className="flex items-center gap-2 shrink-0 ml-3">
|
||||
<button
|
||||
@@ -740,22 +768,35 @@ function PendingApprovals({ onCountChange }: { onCountChange?: (count: number) =
|
||||
})}
|
||||
</div>
|
||||
|
||||
{confirmAction && (
|
||||
<ConfirmDialog
|
||||
isOpen={true}
|
||||
onClose={() => { if (!actionLoading) setConfirmAction(null); }}
|
||||
onConfirm={handleConfirm}
|
||||
title={confirmAction.type === 'approve' ? 'Approve Peering Request' : 'Deny Peering Request'}
|
||||
description={
|
||||
confirmAction.type === 'approve'
|
||||
? `This will initiate a peering handshake with ${confirmAction.request.instanceName || confirmAction.request.origin}. 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.`
|
||||
}
|
||||
confirmLabel={confirmAction.type === 'approve' ? 'Approve' : 'Deny'}
|
||||
variant={confirmAction.type === 'approve' ? 'warning' : 'danger'}
|
||||
loading={!!actionLoading}
|
||||
/>
|
||||
)}
|
||||
{confirmAction && (() => {
|
||||
const isOutbound = confirmAction.request.direction === 'outbound';
|
||||
const targetName = confirmAction.request.instanceName || confirmAction.request.origin;
|
||||
const subCount = confirmAction.request.subscribers?.length ?? 0;
|
||||
const description =
|
||||
confirmAction.type === 'approve'
|
||||
? isOutbound
|
||||
? `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 ${targetName}. The remote instance must be reachable.`
|
||||
: 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.`
|
||||
: `This will deny the request and block future auto-peering requests from ${targetName}. You can unblock them later from the rejected peers list.`;
|
||||
const confirmLabel =
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getActiveRoom } from './useLiveKit';
|
||||
import { useUIStore } from '../stores/uiStore';
|
||||
import { useActivityStore } from '../stores/activityStore';
|
||||
import { useDiscoverStore } from '../stores/discoverStore';
|
||||
import { useFederationStore } from '../stores/federationStore';
|
||||
|
||||
// ─── Rejected peer origins (for unreachable member indicators) ───────────────
|
||||
const rejectedPeerOrigins = new Set<string>();
|
||||
@@ -781,6 +782,33 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
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':
|
||||
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||
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;
|
||||
},
|
||||
}));
|
||||
@@ -20,6 +20,8 @@ export function mapServerErrorToMessage(
|
||||
case 'already_friends': return "You're already friends with this user.";
|
||||
case 'peer_pending_approval':
|
||||
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':
|
||||
return 'Connecting to the remote instance — try again in a moment.';
|
||||
case 'incoming_request_exists':
|
||||
|
||||
Reference in New Issue
Block a user