diff --git a/docs/systems/admin.md b/docs/systems/admin.md index b820c6ba..8e4f6160 100644 --- a/docs/systems/admin.md +++ b/docs/systems/admin.md @@ -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. diff --git a/docs/systems/api.md b/docs/systems/api.md index e3099526..f84412e9 100644 --- a/docs/systems/api.md +++ b/docs/systems/api.md @@ -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? } diff --git a/docs/systems/client-federation.md b/docs/systems/client-federation.md index 76fcc69a..7a40f1ca 100644 --- a/docs/systems/client-federation.md +++ b/docs/systems/client-federation.md @@ -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: diff --git a/docs/systems/database.md b/docs/systems/database.md index e14bd9a5..c64b5ea6 100644 --- a/docs/systems/database.md +++ b/docs/systems/database.md @@ -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) diff --git a/docs/systems/federation.md b/docs/systems/federation.md index ca4d2310..12192163 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -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`. diff --git a/docs/systems/social.md b/docs/systems/social.md index 516c5bca..bcf52093 100644 --- a/docs/systems/social.md +++ b/docs/systems/social.md @@ -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` diff --git a/docs/systems/websocket.md b/docs/systems/websocket.md index 51dee891..fa32e8a3 100644 --- a/docs/systems/websocket.md +++ b/docs/systems/websocket.md @@ -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):** diff --git a/packages/server/drizzle/0003_brave_inhumans.sql b/packages/server/drizzle/0003_brave_inhumans.sql new file mode 100644 index 00000000..f4f436e5 --- /dev/null +++ b/packages/server/drizzle/0003_brave_inhumans.sql @@ -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`); diff --git a/packages/server/drizzle/meta/0003_snapshot.json b/packages/server/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000..684da74d --- /dev/null +++ b/packages/server/drizzle/meta/0003_snapshot.json @@ -0,0 +1,3445 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5ae0d7c7-5409-464d-b8d2-8af70cf6601f", + "prevId": "a603a29a-c462-46c0-b559-5fcfb2453b27", + "tables": { + "attachments": { + "name": "attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dm_message_id": { + "name": "dm_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploader_id": { + "name": "uploader_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mimetype": { + "name": "mimetype", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbnail_filename": { + "name": "thumbnail_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "federation_status": { + "name": "federation_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "federation_meta": { + "name": "federation_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_attachments_message_id": { + "name": "idx_attachments_message_id", + "columns": [ + "message_id" + ], + "isUnique": false + }, + "idx_attachments_dm_message_id": { + "name": "idx_attachments_dm_message_id", + "columns": [ + "dm_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "attachments_message_id_messages_id_fk": { + "name": "attachments_message_id_messages_id_fk", + "tableFrom": "attachments", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_dm_message_id_dm_messages_id_fk": { + "name": "attachments_dm_message_id_dm_messages_id_fk", + "tableFrom": "attachments", + "tableTo": "dm_messages", + "columnsFrom": [ + "dm_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bans": { + "name": "bans", + "columns": { + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banned_by": { + "name": "banned_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_bans_space_id": { + "name": "idx_bans_space_id", + "columns": [ + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bans_space_id_spaces_id_fk": { + "name": "bans_space_id_spaces_id_fk", + "tableFrom": "bans", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bans_user_id_users_id_fk": { + "name": "bans_user_id_users_id_fk", + "tableFrom": "bans", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bans_banned_by_users_id_fk": { + "name": "bans_banned_by_users_id_fk", + "tableFrom": "bans", + "tableTo": "users", + "columnsFrom": [ + "banned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bans_space_id_user_id_pk": { + "columns": [ + "space_id", + "user_id" + ], + "name": "bans_space_id_user_id_pk" + } + }, + "uniqueConstraints": {} + }, + "category_overrides": { + "name": "category_overrides", + "columns": { + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allow": { + "name": "allow", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0'" + }, + "deny": { + "name": "deny", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0'" + } + }, + "indexes": { + "idx_category_overrides_category_id": { + "name": "idx_category_overrides_category_id", + "columns": [ + "category_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "category_overrides_category_id_channel_categories_id_fk": { + "name": "category_overrides_category_id_channel_categories_id_fk", + "tableFrom": "category_overrides", + "tableTo": "channel_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "category_overrides_category_id_target_type_target_id_pk": { + "columns": [ + "category_id", + "target_type", + "target_id" + ], + "name": "category_overrides_category_id_target_type_target_id_pk" + } + }, + "uniqueConstraints": {} + }, + "channel_categories": { + "name": "channel_categories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_channel_categories_space_id": { + "name": "idx_channel_categories_space_id", + "columns": [ + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "channel_categories_space_id_spaces_id_fk": { + "name": "channel_categories_space_id_spaces_id_fk", + "tableFrom": "channel_categories", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "channel_overrides": { + "name": "channel_overrides", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allow": { + "name": "allow", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0'" + }, + "deny": { + "name": "deny", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0'" + } + }, + "indexes": { + "idx_channel_overrides_channel_id": { + "name": "idx_channel_overrides_channel_id", + "columns": [ + "channel_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "channel_overrides_channel_id_channels_id_fk": { + "name": "channel_overrides_channel_id_channels_id_fk", + "tableFrom": "channel_overrides", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_overrides_channel_id_target_type_target_id_pk": { + "columns": [ + "channel_id", + "target_type", + "target_id" + ], + "name": "channel_overrides_channel_id_target_type_target_id_pk" + } + }, + "uniqueConstraints": {} + }, + "channels": { + "name": "channels", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "topic": { + "name": "topic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_channels_space_id": { + "name": "idx_channels_space_id", + "columns": [ + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "channels_space_id_spaces_id_fk": { + "name": "channels_space_id_spaces_id_fk", + "tableFrom": "channels", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "dm_channels": { + "name": "dm_channels", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "federated_id": { + "name": "federated_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_home_user_id": { + "name": "owner_home_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_home_instance": { + "name": "owner_home_instance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_dm_federated": { + "name": "idx_dm_federated", + "columns": [ + "federated_id" + ], + "isUnique": true, + "where": "federated_id IS NOT NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "dm_members": { + "name": "dm_members", + "columns": { + "dm_channel_id": { + "name": "dm_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed": { + "name": "closed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_dm_members_user_id": { + "name": "idx_dm_members_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "dm_members_dm_channel_id_dm_channels_id_fk": { + "name": "dm_members_dm_channel_id_dm_channels_id_fk", + "tableFrom": "dm_members", + "tableTo": "dm_channels", + "columnsFrom": [ + "dm_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dm_members_user_id_users_id_fk": { + "name": "dm_members_user_id_users_id_fk", + "tableFrom": "dm_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dm_members_dm_channel_id_user_id_pk": { + "columns": [ + "dm_channel_id", + "user_id" + ], + "name": "dm_members_dm_channel_id_user_id_pk" + } + }, + "uniqueConstraints": {} + }, + "dm_messages": { + "name": "dm_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "dm_channel_id": { + "name": "dm_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reply_to_id": { + "name": "reply_to_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_instance": { + "name": "source_instance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_message_id": { + "name": "source_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encryption_version": { + "name": "encryption_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_dm_messages_dm_channel_id": { + "name": "idx_dm_messages_dm_channel_id", + "columns": [ + "dm_channel_id" + ], + "isUnique": false + }, + "idx_dm_messages_user_id": { + "name": "idx_dm_messages_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_dm_messages_source_unique": { + "name": "idx_dm_messages_source_unique", + "columns": [ + "source_instance", + "source_message_id" + ], + "isUnique": true, + "where": "source_instance IS NOT NULL" + } + }, + "foreignKeys": { + "dm_messages_dm_channel_id_dm_channels_id_fk": { + "name": "dm_messages_dm_channel_id_dm_channels_id_fk", + "tableFrom": "dm_messages", + "tableTo": "dm_channels", + "columnsFrom": [ + "dm_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dm_messages_user_id_users_id_fk": { + "name": "dm_messages_user_id_users_id_fk", + "tableFrom": "dm_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "dm_messages_reply_to_id_dm_messages_id_fk": { + "name": "dm_messages_reply_to_id_dm_messages_id_fk", + "tableFrom": "dm_messages", + "tableTo": "dm_messages", + "columnsFrom": [ + "reply_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "dm_reactions": { + "name": "dm_reactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "dm_message_id": { + "name": "dm_message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_dm_reactions_dm_message_id": { + "name": "idx_dm_reactions_dm_message_id", + "columns": [ + "dm_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "dm_reactions_dm_message_id_dm_messages_id_fk": { + "name": "dm_reactions_dm_message_id_dm_messages_id_fk", + "tableFrom": "dm_reactions", + "tableTo": "dm_messages", + "columnsFrom": [ + "dm_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dm_reactions_user_id_users_id_fk": { + "name": "dm_reactions_user_id_users_id_fk", + "tableFrom": "dm_reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "embeds": { + "name": "embeds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dm_message_id": { + "name": "dm_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "embed_type": { + "name": "embed_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "embed_url": { + "name": "embed_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_embeds_message_id": { + "name": "idx_embeds_message_id", + "columns": [ + "message_id" + ], + "isUnique": false + }, + "idx_embeds_dm_message_id": { + "name": "idx_embeds_dm_message_id", + "columns": [ + "dm_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "embeds_message_id_messages_id_fk": { + "name": "embeds_message_id_messages_id_fk", + "tableFrom": "embeds", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embeds_dm_message_id_dm_messages_id_fk": { + "name": "embeds_dm_message_id_dm_messages_id_fk", + "tableFrom": "embeds", + "tableTo": "dm_messages", + "columnsFrom": [ + "dm_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "federation_file_queue": { + "name": "federation_file_queue", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "peer_origin": { + "name": "peer_origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dm_message_id": { + "name": "dm_message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_filename": { + "name": "target_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mimetype": { + "name": "mimetype", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "federation_mutation_log": { + "name": "federation_mutation_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_type": { + "name": "context_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dm'" + }, + "mutation_type": { + "name": "mutation_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mutated_at": { + "name": "mutated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_mutation_log_time": { + "name": "idx_mutation_log_time", + "columns": [ + "mutated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "federation_outbox": { + "name": "federation_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "peer_id": { + "name": "peer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_type": { + "name": "context_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dm'" + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryption_version": { + "name": "encryption_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_outbox_retry": { + "name": "idx_outbox_retry", + "columns": [ + "next_retry_at" + ], + "isUnique": false + }, + "federation_outbox_peer_id_entity_id_unique": { + "name": "federation_outbox_peer_id_entity_id_unique", + "columns": [ + "peer_id", + "entity_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "federation_outbox_peer_id_federation_peers_id_fk": { + "name": "federation_outbox_peer_id_federation_peers_id_fk", + "tableFrom": "federation_outbox", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "federation_peers": { + "name": "federation_peers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_name": { + "name": "instance_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hmac_secret": { + "name": "hmac_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_at": { + "name": "last_failure_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "consecutive_auth_failures": { + "name": "consecutive_auth_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remote_max_upload_size": { + "name": "remote_max_upload_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "nonce_supported": { + "name": "nonce_supported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "pending_hmac_secret": { + "name": "pending_hmac_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_rotation_at": { + "name": "secret_rotation_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_rotated_at": { + "name": "secret_rotated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_rotate_interval_days": { + "name": "auto_rotate_interval_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approval_token": { + "name": "approval_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "federation_peers_origin_unique": { + "name": "federation_peers_origin_unique", + "columns": [ + "origin" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "friend_requests": { + "name": "friend_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "from_id": { + "name": "from_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_id": { + "name": "to_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "relay_message_id": { + "name": "relay_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_friend_requests_to_id": { + "name": "idx_friend_requests_to_id", + "columns": [ + "to_id" + ], + "isUnique": false + }, + "idx_friend_requests_from_id": { + "name": "idx_friend_requests_from_id", + "columns": [ + "from_id" + ], + "isUnique": false + }, + "idx_friend_requests_relay_message_id": { + "name": "idx_friend_requests_relay_message_id", + "columns": [ + "relay_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "friend_requests_from_id_users_id_fk": { + "name": "friend_requests_from_id_users_id_fk", + "tableFrom": "friend_requests", + "tableTo": "users", + "columnsFrom": [ + "from_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friend_requests_to_id_users_id_fk": { + "name": "friend_requests_to_id_users_id_fk", + "tableFrom": "friend_requests", + "tableTo": "users", + "columnsFrom": [ + "to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "friends": { + "name": "friends", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "friend_id": { + "name": "friend_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_friends_user_id": { + "name": "idx_friends_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_friends_friend_id": { + "name": "idx_friends_friend_id", + "columns": [ + "friend_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "friends_user_id_users_id_fk": { + "name": "friends_user_id_users_id_fk", + "tableFrom": "friends", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friends_friend_id_users_id_fk": { + "name": "friends_friend_id_users_id_fk", + "tableFrom": "friends", + "tableTo": "users", + "columnsFrom": [ + "friend_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "friends_user_id_friend_id_pk": { + "columns": [ + "user_id", + "friend_id" + ], + "name": "friends_user_id_friend_id_pk" + } + }, + "uniqueConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "instance_name": { + "name": "instance_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'Backspace'" + }, + "worker_id": { + "name": "worker_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "discovery_enabled": { + "name": "discovery_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "max_bitrate_kbps": { + "name": "max_bitrate_kbps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 20000 + }, + "min_bitrate_kbps": { + "name": "min_bitrate_kbps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 500 + }, + "bitrate_step_kbps": { + "name": "bitrate_step_kbps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 500 + }, + "allowed_resolutions": { + "name": "allowed_resolutions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'540,720,1080'" + }, + "allowed_framerates": { + "name": "allowed_framerates", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'30,45,60'" + }, + "max_resolution": { + "name": "max_resolution", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1080 + }, + "max_framerate": { + "name": "max_framerate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 60 + }, + "registration_open": { + "name": "registration_open", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gif_api_key": { + "name": "gif_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bitrate_matrix_overrides": { + "name": "bitrate_matrix_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_custom_bitrate": { + "name": "allow_custom_bitrate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "max_upload_size_bytes": { + "name": "max_upload_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "federation_relay_enabled": { + "name": "federation_relay_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "federation_relay_ttl_days": { + "name": "federation_relay_ttl_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "default_auto_rotate_interval_days": { + "name": "default_auto_rotate_interval_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "auto_accept_peering": { + "name": "auto_accept_peering", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "join_requests": { + "name": "join_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decided_at": { + "name": "decided_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_join_requests_space_id_status": { + "name": "idx_join_requests_space_id_status", + "columns": [ + "space_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "join_requests_space_id_spaces_id_fk": { + "name": "join_requests_space_id_spaces_id_fk", + "tableFrom": "join_requests", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "join_requests_user_id_users_id_fk": { + "name": "join_requests_user_id_users_id_fk", + "tableFrom": "join_requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "join_requests_decided_by_users_id_fk": { + "name": "join_requests_decided_by_users_id_fk", + "tableFrom": "join_requests", + "tableTo": "users", + "columnsFrom": [ + "decided_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "member_roles": { + "name": "member_roles", + "columns": { + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_member_roles_user_id_space_id": { + "name": "idx_member_roles_user_id_space_id", + "columns": [ + "user_id", + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_roles_space_id_spaces_id_fk": { + "name": "member_roles_space_id_spaces_id_fk", + "tableFrom": "member_roles", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_roles_user_id_users_id_fk": { + "name": "member_roles_user_id_users_id_fk", + "tableFrom": "member_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_roles_role_id_roles_id_fk": { + "name": "member_roles_role_id_roles_id_fk", + "tableFrom": "member_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "member_roles_space_id_user_id_role_id_pk": { + "columns": [ + "space_id", + "user_id", + "role_id" + ], + "name": "member_roles_space_id_user_id_role_id_pk" + } + }, + "uniqueConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reply_to_id": { + "name": "reply_to_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_messages_channel_id": { + "name": "idx_messages_channel_id", + "columns": [ + "channel_id" + ], + "isUnique": false + }, + "idx_messages_user_id": { + "name": "idx_messages_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "messages_channel_id_channels_id_fk": { + "name": "messages_channel_id_channels_id_fk", + "tableFrom": "messages", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_user_id_users_id_fk": { + "name": "messages_user_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_reply_to_id_messages_id_fk": { + "name": "messages_reply_to_id_messages_id_fk", + "tableFrom": "messages", + "tableTo": "messages", + "columnsFrom": [ + "reply_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "peer_approval_notifications": { + "name": "peer_approval_notifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "peer_origin": { + "name": "peer_origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_reason": { + "name": "trigger_reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_target": { + "name": "trigger_target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_peer_approval_notifications_user_id": { + "name": "idx_peer_approval_notifications_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "peer_approval_notifications_user_id_users_id_fk": { + "name": "peer_approval_notifications_user_id_users_id_fk", + "tableFrom": "peer_approval_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "peer_approval_requests": { + "name": "peer_approval_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'inbound'" + }, + "instance_name": { + "name": "instance_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hmac_secret": { + "name": "hmac_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_at": { + "name": "requested_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approval_token": { + "name": "approval_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "peer_approval_requests_origin_direction_unique": { + "name": "peer_approval_requests_origin_direction_unique", + "columns": [ + "origin", + "direction" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "peer_approval_subscribers": { + "name": "peer_approval_subscribers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_reason": { + "name": "trigger_reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_target": { + "name": "trigger_target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_peer_approval_subscribers_user_id": { + "name": "idx_peer_approval_subscribers_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "peer_approval_subscribers_request_id_user_id_trigger_reason_trigger_target_unique": { + "name": "peer_approval_subscribers_request_id_user_id_trigger_reason_trigger_target_unique", + "columns": [ + "request_id", + "user_id", + "trigger_reason", + "trigger_target" + ], + "isUnique": true + } + }, + "foreignKeys": { + "peer_approval_subscribers_request_id_peer_approval_requests_id_fk": { + "name": "peer_approval_subscribers_request_id_peer_approval_requests_id_fk", + "tableFrom": "peer_approval_subscribers", + "tableTo": "peer_approval_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "peer_approval_subscribers_user_id_users_id_fk": { + "name": "peer_approval_subscribers_user_id_users_id_fk", + "tableFrom": "peer_approval_subscribers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "reactions": { + "name": "reactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_reactions_message_id": { + "name": "idx_reactions_message_id", + "columns": [ + "message_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "reactions_message_id_messages_id_fk": { + "name": "reactions_message_id_messages_id_fk", + "tableFrom": "reactions", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reactions_user_id_users_id_fk": { + "name": "reactions_user_id_users_id_fk", + "tableFrom": "reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "read_states": { + "name": "read_states", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_read_message_id": { + "name": "last_read_message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_read_states_user_id": { + "name": "idx_read_states_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "read_states_user_id_users_id_fk": { + "name": "read_states_user_id_users_id_fk", + "tableFrom": "read_states", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "read_states_user_id_channel_id_pk": { + "columns": [ + "user_id", + "channel_id" + ], + "name": "read_states_user_id_channel_id_pk" + } + }, + "uniqueConstraints": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'#b9bbbe'" + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_roles_space_id": { + "name": "idx_roles_space_id", + "columns": [ + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "roles_space_id_spaces_id_fk": { + "name": "roles_space_id_spaces_id_fk", + "tableFrom": "roles", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "space_folder_members": { + "name": "space_folder_members", + "columns": { + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "space_folder_members_folder_id_space_folders_id_fk": { + "name": "space_folder_members_folder_id_space_folders_id_fk", + "tableFrom": "space_folder_members", + "tableTo": "space_folders", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "space_folder_members_folder_id_space_id_pk": { + "columns": [ + "folder_id", + "space_id" + ], + "name": "space_folder_members_folder_id_space_id_pk" + } + }, + "uniqueConstraints": {} + }, + "space_folders": { + "name": "space_folders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "space_folders_user_id_users_id_fk": { + "name": "space_folders_user_id_users_id_fk", + "tableFrom": "space_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "space_members": { + "name": "space_members", + "columns": { + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nickname": { + "name": "nickname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_space_members_user_id": { + "name": "idx_space_members_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "space_members_space_id_spaces_id_fk": { + "name": "space_members_space_id_spaces_id_fk", + "tableFrom": "space_members", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "space_members_user_id_users_id_fk": { + "name": "space_members_user_id_users_id_fk", + "tableFrom": "space_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "space_members_space_id_user_id_pk": { + "columns": [ + "space_id", + "user_id" + ], + "name": "space_members_space_id_user_id_pk" + } + }, + "uniqueConstraints": {} + }, + "spaces": { + "name": "spaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banner": { + "name": "banner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_color": { + "name": "avatar_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invite_code": { + "name": "invite_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'private'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "spaces_invite_code_unique": { + "name": "spaces_invite_code_unique", + "columns": [ + "invite_code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "spaces_owner_id_users_id_fk": { + "name": "spaces_owner_id_users_id_fk", + "tableFrom": "spaces", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "user_federation_registry": { + "name": "user_federation_registry", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connected'" + }, + "added_at": { + "name": "added_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_connected_at": { + "name": "last_connected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_federation_registry_user_id_users_id_fk": { + "name": "user_federation_registry_user_id_users_id_fk", + "tableFrom": "user_federation_registry", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_federation_registry_user_id_origin_pk": { + "columns": [ + "user_id", + "origin" + ], + "name": "user_federation_registry_user_id_origin_pk" + } + }, + "uniqueConstraints": {} + }, + "user_space_layout": { + "name": "user_space_layout", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_space_layout_user_id_users_id_fk": { + "name": "user_space_layout_user_id_users_id_fk", + "tableFrom": "user_space_layout", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'offline'" + }, + "custom_status": { + "name": "custom_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "home_instance": { + "name": "home_instance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "home_user_id": { + "name": "home_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "replicated_instances": { + "name": "replicated_instances", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "banner": { + "name": "banner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_color": { + "name": "avatar_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "discoverable": { + "name": "discoverable", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "profile_updated_at": { + "name": "profile_updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_changed_at": { + "name": "password_changed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_activity": { + "name": "show_activity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "federation_registry_updated_at": { + "name": "federation_registry_updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "voice_restrictions": { + "name": "voice_restrictions", + "columns": { + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "restriction_type": { + "name": "restriction_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "moderator_id": { + "name": "moderator_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_voice_restrictions_space_id": { + "name": "idx_voice_restrictions_space_id", + "columns": [ + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "voice_restrictions_space_id_spaces_id_fk": { + "name": "voice_restrictions_space_id_spaces_id_fk", + "tableFrom": "voice_restrictions", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_restrictions_user_id_users_id_fk": { + "name": "voice_restrictions_user_id_users_id_fk", + "tableFrom": "voice_restrictions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_restrictions_moderator_id_users_id_fk": { + "name": "voice_restrictions_moderator_id_users_id_fk", + "tableFrom": "voice_restrictions", + "tableTo": "users", + "columnsFrom": [ + "moderator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "voice_restrictions_space_id_user_id_restriction_type_pk": { + "columns": [ + "space_id", + "user_id", + "restriction_type" + ], + "name": "voice_restrictions_space_id_user_id_restriction_type_pk" + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/server/drizzle/meta/_journal.json b/packages/server/drizzle/meta/_journal.json index 72a4d99a..3880987d 100644 --- a/packages/server/drizzle/meta/_journal.json +++ b/packages/server/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 17c3a696..a97ab2ec 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -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(), diff --git a/packages/server/src/routes/federation.outboundApprove.test.ts b/packages/server/src/routes/federation.outboundApprove.test.ts new file mode 100644 index 00000000..80e38b63 --- /dev/null +++ b/packages/server/src/routes/federation.outboundApprove.test.ts @@ -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>; +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('../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 { + 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([]); + }); +}); diff --git a/packages/server/src/routes/federation.outboundDeny.test.ts b/packages/server/src/routes/federation.outboundDeny.test.ts new file mode 100644 index 00000000..27f66bc1 --- /dev/null +++ b/packages/server/src/routes/federation.outboundDeny.test.ts @@ -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>; +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('../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 { + 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); + }); +}); diff --git a/packages/server/src/routes/federation.peeringNotifications.test.ts b/packages/server/src/routes/federation.peeringNotifications.test.ts new file mode 100644 index 00000000..02ac0027 --- /dev/null +++ b/packages/server/src/routes/federation.peeringNotifications.test.ts @@ -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>; +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('../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 { + 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(); + }); +}); diff --git a/packages/server/src/routes/federation.peeringSubscriptions.test.ts b/packages/server/src/routes/federation.peeringSubscriptions.test.ts new file mode 100644 index 00000000..5db4cee9 --- /dev/null +++ b/packages/server/src/routes/federation.peeringSubscriptions.test.ts @@ -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>; +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('../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 { + 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(); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 34e18bdf..4c1f6435 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { // ─── 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 { } 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 { 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 { .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 { .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(); + 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 { }); } - 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 { 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 }); }, ); diff --git a/packages/server/src/routes/social.ts b/packages/server/src/routes/social.ts index 2db95d7a..4bc66d59 100644 --- a/packages/server/src/routes/social.ts +++ b/packages/server/src/routes/social.ts @@ -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 diff --git a/packages/server/src/utils/federationOutbox.ts b/packages/server/src/utils/federationOutbox.ts index b078f438..08e8d33f 100644 --- a/packages/server/src/utils/federationOutbox.ts +++ b/packages/server/src/utils/federationOutbox.ts @@ -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': diff --git a/packages/server/src/utils/federationPeerActivation.outboundFanout.test.ts b/packages/server/src/utils/federationPeerActivation.outboundFanout.test.ts new file mode 100644 index 00000000..9284075d --- /dev/null +++ b/packages/server/src/utils/federationPeerActivation.outboundFanout.test.ts @@ -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>; +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', + }); + }); +}); diff --git a/packages/server/src/utils/federationPeerActivation.ts b/packages/server/src/utils/federationPeerActivation.ts index c68f8d80..afe32f78 100644 --- a/packages/server/src/utils/federationPeerActivation.ts +++ b/packages/server/src/utils/federationPeerActivation.ts @@ -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 { + 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. diff --git a/packages/server/src/utils/federationPeering.approvalToken.test.ts b/packages/server/src/utils/federationPeering.approvalToken.test.ts index a337738f..385fe076 100644 --- a/packages/server/src/utils/federationPeering.approvalToken.test.ts +++ b/packages/server/src/utils/federationPeering.approvalToken.test.ts @@ -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) diff --git a/packages/server/src/utils/federationPeering.instanceName.test.ts b/packages/server/src/utils/federationPeering.instanceName.test.ts index c19402d5..ca48bd33 100644 --- a/packages/server/src/utils/federationPeering.instanceName.test.ts +++ b/packages/server/src/utils/federationPeering.instanceName.test.ts @@ -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) diff --git a/packages/server/src/utils/federationPeering.outboundGate.test.ts b/packages/server/src/utils/federationPeering.outboundGate.test.ts new file mode 100644 index 00000000..a189b190 --- /dev/null +++ b/packages/server/src/utils/federationPeering.outboundGate.test.ts @@ -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>; +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('./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'); + }); +}); diff --git a/packages/server/src/utils/federationPeering.test.ts b/packages/server/src/utils/federationPeering.test.ts index 2b044795..1a438cf7 100644 --- a/packages/server/src/utils/federationPeering.test.ts +++ b/packages/server/src/utils/federationPeering.test.ts @@ -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 => 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((_, 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 => { 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') { diff --git a/packages/server/src/utils/federationPeering.trustGuard.test.ts b/packages/server/src/utils/federationPeering.trustGuard.test.ts index 56b5cfe9..4d1c4fa9 100644 --- a/packages/server/src/utils/federationPeering.trustGuard.test.ts +++ b/packages/server/src/utils/federationPeering.trustGuard.test.ts @@ -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. diff --git a/packages/server/src/utils/federationPeering.ts b/packages/server/src/utils/federationPeering.ts index 48ce5769..4fdc8527 100644 --- a/packages/server/src/utils/federationPeering.ts +++ b/packages/server/src/utils/federationPeering.ts @@ -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, +): Promise { + 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>(); @@ -40,7 +142,10 @@ const inFlightPeering = new Map>(); * - { 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 { +export async function ensurePeered( + origin: string, + intent: EnsurePeeredCallerIntent, +): Promise { // Validate origin format const normalized = validateOrigin(origin); if (!normalized) { @@ -93,10 +198,20 @@ export async function ensurePeered(origin: string): Promise // 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 }; } + // 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 = ensurePeered, + intent: EnsurePeeredCallerIntent, + ensurePeeredFn: ( + origin: string, + intent: EnsurePeeredCallerIntent, + ) => Promise = ensurePeered, ): Promise { - const handshake = ensurePeeredFn(origin); + const handshake = ensurePeeredFn(origin, intent); let timeoutHandle: ReturnType | undefined; const timeoutPromise = new Promise<{ status: 'timeout' }>(resolve => { diff --git a/packages/server/src/utils/federationWorker.ts b/packages/server/src/utils/federationWorker.ts index e2c89507..dc7cb3c7 100644 --- a/packages/server/src/utils/federationWorker.ts +++ b/packages/server/src/utils/federationWorker.ts @@ -496,7 +496,7 @@ async function resolvePendingPeers(): Promise { 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': diff --git a/packages/server/src/utils/storageJanitor.peeringExpiry.test.ts b/packages/server/src/utils/storageJanitor.peeringExpiry.test.ts new file mode 100644 index 00000000..ce99ba95 --- /dev/null +++ b/packages/server/src/utils/storageJanitor.peeringExpiry.test.ts @@ -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>; +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; + 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(); + }); +}); diff --git a/packages/server/src/utils/storageJanitor.ts b/packages/server/src/utils/storageJanitor.ts index d1ac81bb..df636113 100644 --- a/packages/server/src/utils/storageJanitor.ts +++ b/packages/server/src/utils/storageJanitor.ts @@ -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 { 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 { 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 { 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); } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 678c9121..bf7325e1 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -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[]; +} diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index e2360f9f..d80d49e0 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -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; @@ -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 = { diff --git a/packages/web/src/components/chat/FriendsPage.tsx b/packages/web/src/components/chat/FriendsPage.tsx index 39df158b..14cfc717 100644 --- a/packages/web/src/components/chat/FriendsPage.tsx +++ b/packages/web/src/components/chat/FriendsPage.tsx @@ -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([]); const [searchLoading, setSearchLoading] = useState(false); const [directAddLoading, setDirectAddLoading] = useState(false); diff --git a/packages/web/src/components/modals/ConnectedInstances.tsx b/packages/web/src/components/modals/ConnectedInstances.tsx index 103dd92d..4d60c178 100644 --- a/packages/web/src/components/modals/ConnectedInstances.tsx +++ b/packages/web/src/components/modals/ConnectedInstances.tsx @@ -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 ( +
+
+
+ {actionVerbPhrase(subscription.triggerReason, subscription.triggerTarget)} +
+
+ on {peerLabel} + {subscription.peerInstanceName && ( + ({host}) + )} +
+
+ +
+ ); +} + +function PendingPeeringSubscriptionsSection() { + const subscriptions = useFederationStore((s) => s.peeringSubscriptions); + + if (subscriptions.length === 0) return null; + + return ( +
+
+ Pending Peering Approvals +
+

+ Your admin must approve before these requests can proceed. +

+
+ {subscriptions.map((s) => ( + + ))} +
+
+ ); +} + +// ─── 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 ( + + + + ); + } + if (kind === 'denied') { + return ( + + + + ); + } + return ( + + + + ); +} + +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 ( +
+
+
+ +
+
+
{primaryText}
+
{contextText}
+
+ {showRetry && ( + + )} + +
+
+
+
+ ); +} + +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 ( +
+
+
+ Recent Peering Outcomes +
+ {notifications.length > 1 && ( + + )} +
+
+ {notifications.map((n) => ( + + ))} +
+
+ ); +} + // ─── 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('all'); const [sortBy, setSortBy] = useState('dateAdded'); @@ -1025,7 +1349,15 @@ export function ConnectedInstances() { : null; return ( -
+
+ {/* Terminal-state outcomes first — newly resolved requests warrant the + user's attention (especially approvals they can now retry). */} + + + {/* Active waiting state. */} + + +
Connected Instances
@@ -1109,6 +1441,7 @@ export function ConnectedInstances() { )}
+
); } diff --git a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx index bf313a8d..1edfdaf4 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx @@ -693,16 +693,44 @@ function PendingApprovals({ onCountChange }: { onCountChange?: (count: number) =
Loading...
)} {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 (
-
{name}
+
{titleText}
{req.origin}
Requested {formatRelativeTime(req.requestedAt)}
+ {isOutbound && req.subscribers && req.subscribers.length > 0 && ( +
+ {req.subscribers.map((sub) => ( +
+ {sub.username} + {' — '} + {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}`} +
+ ))} +
+ )}
); } diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 760f2279..f86a1131 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -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(); @@ -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); diff --git a/packages/web/src/stores/federationStore.ts b/packages/web/src/stores/federationStore.ts new file mode 100644 index 00000000..31efb581 --- /dev/null +++ b/packages/web/src/stores/federationStore.ts @@ -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; + refetchPeeringNotifications: () => Promise; + cancelPeeringSubscription: (id: string) => Promise; + markPeeringNotificationRead: (id: string) => Promise; + markAllPeeringNotificationsRead: () => Promise; + + setPendingFriendAddPrefill: (value: string | null) => void; + consumePendingFriendAddPrefill: () => string | null; +} + +export const useFederationStore = create((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; + }, +})); diff --git a/packages/web/src/utils/friendErrors.ts b/packages/web/src/utils/friendErrors.ts index ec1531a5..12fddf4f 100644 --- a/packages/web/src/utils/friendErrors.ts +++ b/packages/web/src/utils/friendErrors.ts @@ -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':