Merge branch 'feat/s2s-friend-add'

This commit is contained in:
Jannis Braun
2026-04-26 00:01:26 +02:00
34 changed files with 5762 additions and 386 deletions
+25 -1
View File
@@ -118,7 +118,7 @@ DELETE /dm/messages/:id → { succes
``` ```
GET /social/friends → { friends[] } GET /social/friends → { friends[] }
GET /social/requests → { requests[] } GET /social/requests → { requests[] }
POST /social/requests { username } → { request } POST /social/requests { username } → { success, requestId }
PATCH /social/requests/:id { status: 'accepted'|'declined' } → { request } PATCH /social/requests/:id { status: 'accepted'|'declined' } → { request }
DELETE /social/requests/:id → { success } (cancel, sender-only) DELETE /social/requests/:id → { success } (cancel, sender-only)
DELETE /social/friends/:id → { success } DELETE /social/friends/:id → { success }
@@ -126,6 +126,27 @@ GET /social/discover ?q=&limit=&offset= → { users[], total }
GET /social/search ?q= → { users[] } GET /social/search ?q= → { users[] }
``` ```
### POST /api/social/requests — routing & error codes
`body.username` may be `bare` (local), `bare@<own host>` (also routed local — server normalizes), or `bare@<remote host>` (federated branch). The client sends the trimmed handle verbatim; all parsing, routing, peering, and remote lookup are server-side.
| HTTP | error code | When |
|---|---|---|
| 200 | (success, idempotent) | Same-direction pending request already exists; returns existing `requestId` |
| 201 | (success, created) | New friend request created |
| 400 | `username_required` | Missing/empty/non-string username |
| 400 | `cannot_friend_self` | Looked-up identity matches sender |
| 400 | `invalid_target_domain` | Scheme resolution failed (e.g., non-localhost HTTP target when our scheme is HTTPS) |
| 403 | `peer_rejected` | Remote instance has rejected federation; admin must intervene |
| 403 | `not_authoritative_for_sender` | Caller is a federated (replicated) user; should not have reached here |
| 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` | 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 |
| 503 | `peer_unreachable` | Remote instance unreachable (network/timeout/lookup-unreachable) |
## Search (`routes/search.ts`) — auth required ## Search (`routes/search.ts`) — auth required
``` ```
GET /channels/:id/search ?q=&from=&has=&before=&after=&offset=&limit= → { results[], totalCount } [VIEW_CHANNEL] GET /channels/:id/search ?q=&from=&has=&before=&after=&offset=&limit= → { results[], totalCount } [VIEW_CHANNEL]
@@ -201,8 +222,11 @@ GET /federation/peers (admin)
DELETE /federation/peers/:id (admin) → { success } + outbox cleanup DELETE /federation/peers/:id (admin) → { success } + outbox cleanup
POST /federation/relay (HMAC-signed S2S) FederationRelayRequest → { accepted[], rejected[] } POST /federation/relay (HMAC-signed S2S) FederationRelayRequest → { accepted[], rejected[] }
POST /federation/sync (HMAC-signed S2S) { sinceTimestamp, limit?, dmChannelId?, federatedId?, contextType? } → { events[], hasMore, checkpoint } POST /federation/sync (HMAC-signed S2S) { sinceTimestamp, limit?, dmChannelId?, federatedId?, contextType? } → { events[], hasMore, checkpoint }
POST /federation/users/lookup (HMAC-signed S2S, rate-limited 60/min/peer) { username } → { found, user? }
``` ```
**`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.
## Utilities (`routes/utils.ts`) — auth required ## Utilities (`routes/utils.ts`) — auth required
``` ```
GET /utils/metadata ?url= → { title?, description?, image?, siteName? } GET /utils/metadata ?url= → { title?, description?, image?, siteName? }
+13 -1
View File
@@ -15,6 +15,10 @@ Source files:
## Architecture Overview ## Architecture Overview
**Client-side vs S2S federation by feature:**
- **Friend & DM relay are S2S.** Sending a friend request to `alice@orbit.tld` does not require having a federated account on `orbit.tld`; the sender's home server queues the relay (see `social.md` §6 outbound flow). DM messages are similarly relayed server-to-server once the initial channel exists.
- **Spaces are client-federated.** Joining a remote space still requires creating a federated account on that instance via the Connections UI.
Backspace supports **client-side federation**: a single app session (web or desktop — both are feature-identical) can connect to multiple Backspace instances simultaneously. The user has a **home instance** (their primary identity) and zero or more **connected remote instances**. Backspace supports **client-side federation**: a single app session (web or desktop — both are feature-identical) can connect to multiple Backspace instances simultaneously. The user has a **home instance** (their primary identity) and zero or more **connected remote instances**.
``` ```
@@ -50,7 +54,9 @@ Backspace supports **client-side federation**: a single app session (web or desk
## 1. Federated Account Creation ## 1. Federated Account Creation
When a user connects to a remote instance, the client creates (or logs into) an account on that instance. This is a **real account with a real bcrypt password** — not a replicated stub. When a user adds a remote instance via the Connections settings to **join a space there**, the client creates (or logs into) an account on that instance. As of 2026-04-25, this is no longer required for friending or messaging users on a remote instance — those flows are fully S2S (see `social.md` §6 and `federation.md` §4 respectively). Federated accounts remain real loginable accounts and retain all capabilities (login, space membership, password sync, deletion).
This is a **real account with a real bcrypt password** — not a replicated stub.
### Username Format ### Username Format
@@ -80,6 +86,12 @@ When a user adds a remote instance via the Connections settings:
The same password is used across all instances. Password changes on the home instance are synced to remote instances automatically. The same password is used across all instances. Password changes on the home instance are synced to remote instances automatically.
### API Client Error Contract
The shared API client (`packages/web/src/api/client.ts:298`) throws `new Error(body.error)` for non-2xx responses. The server's structured error code is on `err.message`; there is **no** `err.body` or `err.code` property. Catch handlers that need to map codes to UI messages should read `err.message` and pass it as both the code and the fallback to `mapServerErrorToMessage` (see `packages/web/src/utils/friendErrors.ts`).
This was documented after T19/T20 catch blocks initially read the wrong shape and surfaced raw codes as toast text — fixed in commit `d207af4`. The same pattern applies to any new client-side code that catches API errors from the home or remote instances.
--- ---
## 2. Instance Store (`instanceStore.ts`) ## 2. Instance Store (`instanceStore.ts`)
+28 -4
View File
@@ -183,6 +183,7 @@ When `autoAcceptPeering` is `false` and an instance calls `POST /api/federation/
| `/api/federation/peer/rotate` | POST | HMAC | Accept secret rotation from peer | | `/api/federation/peer/rotate` | POST | HMAC | Accept secret rotation from peer |
| `/api/federation/peer/denied` | POST | HMAC | Receive denial notification for awaiting_approval peer | | `/api/federation/peer/denied` | POST | HMAC | Receive denial notification for awaiting_approval peer |
| `/api/federation/identity` | DELETE | HMAC | Delete federated user identity (soft/full mode) | | `/api/federation/identity` | DELETE | HMAC | Delete federated user identity (soft/full mode) |
| `/api/federation/users/lookup` | POST | HMAC, rate-limited 60/min/peer | Resolve a username on this instance to (homeUserId, profile snapshot) for cross-instance friend-request originators |
### S2S Identity Deletion (`DELETE /api/federation/identity`) ### S2S Identity Deletion (`DELETE /api/federation/identity`)
@@ -202,6 +203,22 @@ Allows a home instance to remove a user's replicated identity from a remote inst
- **Mode `"full"`:** Calls `tombstoneUser(uid, { purgeContent: true })` — full tombstone including reactions and orphaned DM cleanup. - **Mode `"full"`:** Calls `tombstoneUser(uid, { purgeContent: true })` — full tombstone including reactions and orphaned DM cleanup.
- **Post-deletion:** Broadcasts `member_left` WS events for all spaces the user belonged to before removal. - **Post-deletion:** Broadcasts `member_left` WS events for all spaces the user belonged to before removal.
### S2S User Lookup (`POST /api/federation/users/lookup`)
HMAC-authenticated. Rate-limited to 60 requests/minute per peer. Used by the cross-instance friend-add flow to resolve a username on this instance before the sender's home server queues a `friend_request_create` event (see `social.md` §6 outbound flow).
**Request body:** `{ username: string }` — server trims and lowercases before lookup.
**Response 200:** `{ found: true, user: { homeUserId, username, profile: { displayName, avatar, avatarColor, banner, bio } } }` — returned for native, non-deleted users regardless of their `discoverable` setting.
**Response 404:** `{ found: false, code: 'user_not_found' }` — returned for tombstoned users (`isDeleted=1`), replicated stubs (`homeInstance IS NOT NULL`), or unknown usernames.
**Response 400:** Malformed input (missing, non-string, or empty-after-trim `username`).
**Response 429:** Per-peer rate limit (60/min) exceeded; `Retry-After` header set.
**Filter invariant:** `discoverable` is NOT consulted — exact-handle resolution must work for opted-out users. This mirrors the Direct-Add invariant from social.md commits 69d430b/c60ecc5/189cac4: discovery surfaces only opted-in users, but once you have a handle you can always send a request.
### WebSocket Events (Peering) ### WebSocket Events (Peering)
These S→C events are pushed to the acting user's connected clients by the federation subsystem. These S→C events are pushed to the acting user's connected clients by the federation subsystem.
@@ -487,14 +504,21 @@ Trigger (API/WS handler)
- Increment peer `consecutiveFailures`, set `lastFailureAt` - Increment peer `consecutiveFailures`, set `lastFailureAt`
- If `consecutiveFailures >= PEER_UNREACHABLE_THRESHOLD (10)` -> mark peer `unreachable` - If `consecutiveFailures >= PEER_UNREACHABLE_THRESHOLD (10)` -> mark peer `unreachable`
#### Terminal rejection: `duplicate` #### Terminal rejection reasons
The outbox delivery worker treats a relay response of `{ rejected: [{ reason: 'duplicate', ... }] }` as effectively-accepted — the outbox entry is deleted rather than retained for retry. The `duplicate` reason is emitted by the receiving instance's inbound processors when a row with the same `(sourceInstance, sourceMessageId)` already exists; retrying will fail identically until TTL (30 days). Since the peer already has the message, terminal removal is the correct outcome. As of 2026-04-25, `processOutboxTick` recognizes a configurable set of receiver-acknowledged **terminal rejection reasons** (constant `TERMINAL_REJECTION_REASONS` in `federationWorker.ts`): `duplicate`, `recipient_not_found`, `attribution_mismatch`, `unknown_event_type`. Outbox entries with these reasons are deleted with no retry.
- `duplicate` — the receiving instance already has the row (same `(sourceInstance, sourceMessageId)`); retrying will fail identically until TTL.
- `recipient_not_found`, `attribution_mismatch`, `unknown_event_type` — structural mismatches that cannot be resolved by retrying.
For non-`duplicate` terminals, the worker invokes a registered permanent-failure callback via `invokePermanentFailureCallback(eventType, messageId, reason)` from `utils/federationRollback.ts`. Currently registered: `friend_request_create``rollbackFriendRequestCreate` (deletes the local `friend_requests` row by `relay_message_id` and emits WS `friend_request_relay_failed` to the sender). Other event types may register their own callbacks. See `social.md` §6 "Failure Handling" for the friend-specific rollback contract.
**Ghost-row failure mode.** Rollback callbacks are best-effort: the registry catches and logs callback errors but does NOT re-throw. A botched rollback (e.g., DB write fails mid-rollback due to disk full or FK violation) can leave a ghost row that no longer corresponds to any in-flight relay. Acceptable vs. retry-forever blocking the outbox, but worth knowing when debugging stuck pending states.
**5xx and network failures are NOT terminal** — those use the existing exponential-backoff retry path. Pending operations stay pending indefinitely under sustained connectivity loss, matching the pre-existing DM relay behavior.
Logged at `console.log` ("outbox entry removed (terminal)") to distinguish from retained-for-retry `console.warn` messages. Logged at `console.log` ("outbox entry removed (terminal)") to distinguish from retained-for-retry `console.warn` messages.
Other rejection reasons (`attribution_mismatch`, `missing_*_payload`, `unknown_event_type`, `unauthorized_source`, `channel_not_found`, `participant_not_found`, `processing_error`, …) remain on the retry path. Some are arguably terminal too; treating them as such is deferred until they are observed accumulating in practice.
### Retry Backoff Schedule ### Retry Backoff Schedule
| Attempt | Delay | | Attempt | Delay |
+54 -21
View File
@@ -287,13 +287,36 @@ The sorted join ensures the same pair always produces the same prefix regardless
### End-to-End Relay Flow: Friend Request Create ### End-to-End Relay Flow: Friend Request Create
**Outbound (origin instance -- `social.ts:POST /api/social/requests`):** **Outbound (sender's home instance -- `social.ts:POST /api/social/requests`):**
1. Friend request created locally (DB insert + WS broadcast to local recipient)
2. Build identity objects for sender and recipient using `homeUserId || id` / `homeInstance || getOurOrigin()` As of 2026-04-25, the sender's home server owns the entire federated friend-add flow. The client sends `{ username }` verbatim; all parsing, peering, remote lookup, and queueing happen server-side in this strict order:
3. Call `getFriendEventTargets(from.homeInstance, to.homeInstance)` -- if both local, returns `[]` (no relay)
4. Build `FederationRelayEvent` with `eventType: 'friend_request_create'`, `contextType: 'friend'`, friendship payload including both identities and profile snapshots 1. **Parse target.** If `body.username` contains no `@`, or the domain after `@` normalizes to this server's own host, fall through to the local-only path (unchanged).
5. Call `appendMutationLog()` -- records in `federation_mutation_log` for sync protocol 2. **resolveOriginFromHostname(targetDomain)** — resolves the target peer's full origin URL. Prefers a stored `federation_peers` row matching the typed host; falls back to mirroring `getOurOrigin()`'s scheme. Returns null → 400 `invalid_target_domain`.
6. Call `queueOutboxEvent()` -- inserts into `federation_outbox` for each target peer 3. **Authority defense.** If the calling user's `homeInstance` is set and does not normalize to this server's own host (checked via `normalizeOriginForCompare`), return 403 `not_authoritative_for_sender`. Prevents replicated/federated users from queueing relay events the home server isn't authoritative for. Runs before peering to fail fast.
4. **ensurePeered(peerOrigin)** — blocks on the result. Status → HTTP mapping:
- `'active'` → continue
- `'pending'` (handshake in flight) → 409 `peer_pending`
- `'pending'` + peer row `awaiting_approval` (re-queried after the call) → 409 `peer_pending_approval`
- `'rejected'` → 403 `peer_rejected`
- `'failed'` → 503 `peer_unreachable`
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`
- `rate_limited` → 429 `lookup_rate_limited` (with `Retry-After` header)
6. **Self-friend pre-check.** If the looked-up `(homeUserId, peerOrigin)` matches the sender's canonical identity (using `normalizeOriginForCompare` for host comparison) → 400 `cannot_friend_self`.
7. **resolveOrCreateReplicatedUser + hydrateReplicatedUserProfile** — creates or refreshes the local stub for the remote user. Tombstoned identities (resolveOrCreateReplicatedUser returns null) → 404 `user_not_found`.
8. **Direction-aware idempotency:**
- Same-direction pending request exists → 200 with existing `requestId` (idempotent).
- Opposite-direction pending request exists → 409 `incoming_request_exists` with existing `requestId` for client deep-link.
- Already friends → 409 `already_friends`.
9. **db.transaction(...)** (synchronous): inserts the `friend_requests` row with `relayMessageId = entityId`, calls `appendMutationLog`, calls `queueOutboxEvent` targeting `[peerOrigin]`.
10. **WS broadcast** `friend_request_sent` to the sender's other tabs/devices (multi-tab sync).
11. Returns `201 { success: true, requestId }`.
The wire format of the queued event is identical to the pre-2026-04-25 flow; only the queueing instance has changed. The receiver's `processFriendRequestCreateEvent` is unchanged. Both peers' authority checks (`from.homeInstance === sourceInstance`) continue to pass because the sender's instance is now both source and queueing instance.
> **Schema note:** The `friend_requests` table gained a `relayMessageId TEXT` column (added 2026-04-25, drizzle migration `0001_complex_screwball.sql`). It is `NULL` for local-only requests; for federated ones it carries the `entityId` of the originating relay event so the rollback hook can locate the row by message ID.
**Delivery (federation worker -- `federationWorker.ts:processOutboxTick`):** **Delivery (federation worker -- `federationWorker.ts:processOutboxTick`):**
1. Worker polls outbox every 10 seconds 1. Worker polls outbox every 10 seconds
@@ -311,6 +334,22 @@ The sorted join ensures the same pair always produces the same prefix regardless
7. **WS broadcast:** `friend_request_received` sent to local recipient with sender's sanitized profile 7. **WS broadcast:** `friend_request_received` sent to local recipient with sender's sanitized profile
8. Push `event.messageId` to accepted array 8. Push `event.messageId` to accepted array
### Failure Handling: Async Rollback
When the outbox worker receives a relay response from the remote instance, it classifies each rejected entry. As of 2026-04-25, a configurable set of **terminal rejection reasons** (`TERMINAL_REJECTION_REASONS` in `federationWorker.ts`) causes an outbox entry to be deleted with no retry: `duplicate`, `recipient_not_found`, `attribution_mismatch`, `unknown_event_type`.
For non-`duplicate` terminals, the worker invokes the registered permanent-failure callback via `invokePermanentFailureCallback(eventType, messageId, reason)` from `utils/federationRollback.ts`. For `friend_request_create`, this is **`rollbackFriendRequestCreate`**:
1. Looks up the `friend_requests` row by `relayMessageId` (the stored `entityId`).
2. Deletes the row.
3. Emits WS `friend_request_relay_failed` to the sender's connections, with a client-facing reason: receiver `recipient_not_found``user_not_found`; everything else → `peer_rejected`.
The client handler in `useWebSocket.ts` removes the row from `socialStore` and shows a warning toast.
**5xx responses, network errors, and retry exhaustion are NOT terminal** — the outbox retries with exponential backoff. The sender sees indefinite "pending" under sustained connectivity loss, matching DM relay's behavior under the same conditions.
**Ghost-row risk.** Rollback callbacks are best-effort: the registry catches and logs callback errors but does not re-throw. A failed rollback (e.g., DB write fails mid-rollback) leaves a ghost `friend_requests` row with no corresponding in-flight relay. Acceptable vs. retry-forever blocking the outbox, but worth knowing when debugging stuck pending requests.
### End-to-End Relay Flow: Friend Request Update (Accept/Decline) ### End-to-End Relay Flow: Friend Request Update (Accept/Decline)
**Outbound (`social.ts:PATCH /api/social/requests/:id`):** **Outbound (`social.ts:PATCH /api/social/requests/:id`):**
@@ -411,18 +450,11 @@ type TaggedUser = User & { _instanceOrigin: string };
Same `Promise.allSettled()` fan-out pattern as `loadFriends`. **Dedup by the other party's canonical identity** (`request.user.homeUserId ?? request.user.id`), preferring the record from the instance where the other party is native (`!request.user.homeInstance`). This is critical: a cross-instance request exists as two rows -- one on each instance -- and both sides return it, but only the record from the target's home instance has the canonical (non-stub) user ids and the correct `_instanceOrigin` tag. Matching those is what lets the Add Friend search card flip to "Request Pending" after sending. Normalizes assets for remote request user profiles. Same `Promise.allSettled()` fan-out pattern as `loadFriends`. **Dedup by the other party's canonical identity** (`request.user.homeUserId ?? request.user.id`), preferring the record from the instance where the other party is native (`!request.user.homeInstance`). This is critical: a cross-instance request exists as two rows -- one on each instance -- and both sides return it, but only the record from the target's home instance has the canonical (non-stub) user ids and the correct `_instanceOrigin` tag. Matching those is what lets the Add Friend search card flip to "Request Pending" after sending. Normalizes assets for remote request user profiles.
### Sending Friend Requests (Federation Routing) ### Sending Friend Requests
`sendFriendRequest(username: string)` handles the `user@domain` routing: `sendFriendRequest(username: string)` sends the trimmed handle verbatim to the home instance API (`POST /api/social/requests`). As of 2026-04-25, all routing, peering, and remote lookup happen server-side — the client no longer resolves the domain to a connected instance or throws `InstanceNotConnectedError`/`InstanceDisconnectedError`. The server returns a structured error code on any failure; the catch block in `socialStore` maps it via `mapServerErrorToMessage` from `packages/web/src/utils/friendErrors.ts` and surfaces it as a toast.
1. **No `@` in username:** Send to home instance API directly After success, reloads requests via `loadRequests()`.
2. **`@` present:** Extract `baseName` and `domain` via `lastIndexOf('@')`
- If domain matches `window.location.host`: strip domain, send to home API
- Otherwise: find matching connected instance by comparing `new URL(inst.origin).host === domain`
- **Not found:** Throw `InstanceNotConnectedError(domain)` (UI prompts to connect)
- **Found but disconnected:** Throw `InstanceDisconnectedError(domain)` (UI prompts to reconnect)
- **Found and connected:** Send to remote instance API with just the `baseName` (no domain suffix)
3. After success, reloads requests via `loadRequests()`
### Cross-Instance Search (`searchUsers`) ### Cross-Instance Search (`searchUsers`)
@@ -592,7 +624,9 @@ The Add Friend tab merges search and discovery into a single UI:
1. **Empty query:** Shows discover grid (from `discoverStore.fetchUsers()`, loaded on mount) 1. **Empty query:** Shows discover grid (from `discoverStore.fetchUsers()`, loaded on mount)
2. **Query entered:** Switches to search mode (debounced 300ms, uses `socialStore.searchUsers()`) 2. **Query entered:** Switches to search mode (debounced 300ms, uses `socialStore.searchUsers()`)
3. **Direct-Add row:** Shown whenever the search input is non-empty and resolves to a well-formed handle (`trimmed.length > 0 && (no @ || @ at non-edge position)`). Displays the resolved form: when the typed query has no `@`, the row shows `<query>@<window.location.host>` so the user sees which instance the request will hit; when `@` is present, displays the typed query verbatim. The submit button calls `sendFriendRequest(query.trim())` — the resolved form is display-only; routing to the correct API client (home-only for no-`@`, federation-aware for `@host` form) happens inside `sendFriendRequest`, which lowercases the parsed `@domain` before comparison so mixed-case hostnames route correctly. Server-side `POST /api/social/requests` lowercases the lookup input before matching, so mixed-case bare handles resolve too. A 404 from the server (`"User not found"`) surfaces as a warning toast via the catch-all in `handleDirectAdd`. 3. **Direct-Add row:** Shown whenever the search input is non-empty and resolves to a well-formed handle (`trimmed.length > 0 && (no @ || @ at non-edge position)`). Displays the resolved form: when the typed query has no `@`, the row shows `<query>@<window.location.host>` so the user sees which instance the request will hit; when `@` is present, displays the typed query verbatim. The submit button calls `sendFriendRequest(query.trim())` — the resolved form is display-only. All routing, peering, and remote lookup happen server-side on `POST /api/social/requests` (see §6 outbound flow). Server-side `POST /api/social/requests` lowercases the lookup input before matching, so mixed-case bare handles resolve too.
**Error handling:** Server errors surface as toasts via `mapServerErrorToMessage` in `packages/web/src/utils/friendErrors.ts`. All structured error codes returned by the federated branch (`user_not_found`, `peer_pending`, `peer_rejected`, `incoming_request_exists`, etc.) are mapped to human-readable messages there. The `ConnectInstanceModal` component still exists in the codebase but is no longer triggered by friend-add — it is used only by the Connections settings panel and space-join flows.
### Search Result Enrichment ### Search Result Enrichment
@@ -612,7 +646,7 @@ Renders a card with banner, avatar, display name, username, bio, mutual counts,
- `inbound_pending`: "Accept" / "Decline" buttons - `inbound_pending`: "Accept" / "Decline" buttons
- `friends`: "Message" button - `friends`: "Message" button
When sending a request to a remote user, constructs `baseName@originHost` format for the username. Handles `InstanceNotConnectedError` / `InstanceDisconnectedError` by showing `ConnectInstanceModal`. When sending a request to a remote user, constructs `baseName@originHost` format for the username. Errors from the server are surfaced as toasts via `mapServerErrorToMessage` (see `packages/web/src/utils/friendErrors.ts`).
--- ---
@@ -657,8 +691,7 @@ All actions route through `socialStore` methods, which handle instance routing v
- User profile is loaded via `getApiForOrigin(origin)` to fetch from the correct instance - User profile is loaded via `getApiForOrigin(origin)` to fetch from the correct instance
- Banner/avatar URLs resolved through the correct API client for remote users - Banner/avatar URLs resolved through the correct API client for remote users
- Mutuals loaded via `loadFederatedMutuals()` with cross-instance fan-out - Mutuals loaded via `loadFederatedMutuals()` with cross-instance fan-out
- Friend actions use `sendFriendRequest(user.username)` which handles `user@domain` routing - Friend actions use `sendFriendRequest(user.username)` — all routing is server-side; errors surface as toasts via `mapServerErrorToMessage`
- `ConnectInstanceModal` shown when trying to add a friend on an unconnected instance
--- ---
@@ -0,0 +1,2 @@
ALTER TABLE `friend_requests` ADD `relay_message_id` text;--> statement-breakpoint
CREATE INDEX `idx_friend_requests_relay_message_id` ON `friend_requests` (`relay_message_id`);
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,13 @@
"when": 1777065939127, "when": 1777065939127,
"tag": "0000_lethal_wildside", "tag": "0000_lethal_wildside",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1777144927946,
"tag": "0001_complex_screwball",
"breakpoints": true
} }
] ]
} }
+2
View File
@@ -194,9 +194,11 @@ export const friendRequests = sqliteTable('friend_requests', {
toId: text('to_id').notNull().references(() => users.id, { onDelete: 'cascade' }), toId: text('to_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
status: text('status').default('pending'), // 'pending', 'accepted', 'declined' status: text('status').default('pending'), // 'pending', 'accepted', 'declined'
createdAt: integer('created_at').notNull(), createdAt: integer('created_at').notNull(),
relayMessageId: text('relay_message_id'), // entityId of the originating relay event; null for local-only requests. Used by the rollback hook in federationRollback.ts to find rows for deletion when a federated friend_request_create is permanently rejected.
}, (table) => ({ }, (table) => ({
toIdx: index('idx_friend_requests_to_id').on(table.toId), toIdx: index('idx_friend_requests_to_id').on(table.toId),
fromIdx: index('idx_friend_requests_from_id').on(table.fromId), fromIdx: index('idx_friend_requests_from_id').on(table.fromId),
relayMessageIdx: index('idx_friend_requests_relay_message_id').on(table.relayMessageId),
})); }));
export const reactions = sqliteTable('reactions', { export const reactions = sqliteTable('reactions', {
+1
View File
@@ -26,6 +26,7 @@ import { adminRoutes } from './routes/admin.js';
import { gifRoutes } from './routes/gif.js'; import { gifRoutes } from './routes/gif.js';
import { federationRoutes } from './routes/federation.js'; import { federationRoutes } from './routes/federation.js';
import { startFederationWorkers, stopFederationWorkers } from './utils/federationWorker.js'; import { startFederationWorkers, stopFederationWorkers } from './utils/federationWorker.js';
import './utils/federationRollback.js'; // Side-effect: registers rollback callbacks for outbox terminal failures.
import { registerCallRelayHooks } from './ws/events.js'; import { registerCallRelayHooks } from './ws/events.js';
import { registerWebSocket } from './ws/handler.js'; import { registerWebSocket } from './ws/handler.js';
@@ -0,0 +1,116 @@
import { describe, it, expect, beforeEach, 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 '../utils/snowflake.js';
import type { FederationRelayEvent } from '@backspace/shared';
setWorkerId(1);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
let sqlite: Database.Database;
let testDb: ReturnType<typeof drizzle<typeof schema>>;
const sendToUser = vi.fn();
vi.mock('../db/index.js', () => ({ getDb: () => testDb, getRawDb: () => sqlite, schema }));
vi.mock('../ws/handler.js', () => ({
connectionManager: { sendToUser, sendToAdmins: vi.fn(), sendToDmMembers: vi.fn(), getAllOnlineUserIds: () => [] },
}));
vi.mock('../utils/federationAuth.js', async (importActual) => {
const actual = await importActual<typeof import('../utils/federationAuth.js')>();
return { ...actual, getOurOrigin: () => 'https://home.test' };
});
function applyMigrations(db: Database.Database): void {
const dir = path.resolve(__dirname, '../../drizzle');
for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) {
const sqlText = fs.readFileSync(path.join(dir, f), 'utf8');
for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
sendToUser.mockReset();
});
describe('processRelayEvents — friend_request_create from sender home (S2S friend-add wire format)', () => {
it('accepts the event, creates the local request, hydrates the sender stub, broadcasts to recipient', async () => {
// Seed local recipient
testDb.insert(schema.users).values({
id: 'alice-id',
username: 'alice',
passwordHash: 'x',
status: 'online',
isAdmin: 0,
createdAt: Date.now(),
} as typeof schema.users.$inferInsert).run();
// Seed active peer for the source instance
testDb.insert(schema.federationPeers).values({
id: 'peer-orbit',
origin: 'https://orbit.test',
hmacSecret: 'a'.repeat(64),
status: 'active',
nonceSupported: 1,
createdAt: Date.now(),
consecutiveFailures: 0,
consecutiveAuthFailures: 0,
} as typeof schema.federationPeers.$inferInsert).run();
const event: FederationRelayEvent = {
eventType: 'friend_request_create',
contextType: 'friend',
messageId: 'friend_req:alice-id:remote-bob:12345',
encryptionVersion: 0,
timestamp: 12345,
friendship: {
from: { homeUserId: 'remote-bob', homeInstance: 'https://orbit.test' },
to: { homeUserId: 'alice-id', homeInstance: 'https://home.test' },
fromProfile: { username: 'bob', displayName: 'Bob', avatar: null, avatarColor: 'mint', banner: null, bio: null },
toProfile: { username: 'alice', displayName: 'Alice', avatar: null, avatarColor: 'rose', banner: null, bio: null },
status: 'pending',
createdAt: 12345,
},
};
const { processRelayEvents } = await import('./federation.js');
const result = await processRelayEvents([event], 'https://orbit.test', 'https://orbit.test', testDb);
// Wire-format invariant: accepted, no rejection.
expect(result.accepted).toEqual(['friend_req:alice-id:remote-bob:12345']);
expect(result.rejected).toEqual([]);
// Stub for sender was auto-created. Note: resolveOrCreateReplicatedUser
// stores homeInstance as bare host (extractDomain), not full URL.
const bobStub = testDb.select().from(schema.users)
.where(eq(schema.users.homeUserId, 'remote-bob')).get();
expect(bobStub).toBeDefined();
expect(bobStub!.homeInstance).toBe('orbit.test');
// Friend request row inserted: from=stub, to=alice.
const reqs = testDb.select().from(schema.friendRequests).all();
expect(reqs).toHaveLength(1);
expect(reqs[0]!.toId).toBe('alice-id');
expect(reqs[0]!.fromId).toBe(bobStub!.id);
expect(reqs[0]!.status).toBe('pending');
// WS broadcast to alice with friend_request_received and the bob profile.
expect(sendToUser).toHaveBeenCalledOnce();
const [userId, evt] = sendToUser.mock.calls[0]!;
expect(userId).toBe('alice-id');
expect(evt.type).toBe('friend_request_received');
expect(evt.request.fromId).toBe(bobStub!.id);
expect(evt.request.toId).toBe('alice-id');
expect(evt.request.user?.displayName).toBe('Bob');
});
});
+119 -1
View File
@@ -136,6 +136,32 @@ function isRelayRateLimited(peerOrigin: string): boolean {
return false; return false;
} }
// ─── In-memory rate limiter for the user-lookup endpoint (per-peer) ──────────
const lookupRateBuckets = new Map<string, number[]>();
const LOOKUP_RATE_WINDOW_MS = 60_000;
const LOOKUP_RATE_MAX = 60;
function isLookupRateLimited(peerOrigin: string): boolean {
const now = Date.now();
let timestamps = lookupRateBuckets.get(peerOrigin);
if (!timestamps) {
timestamps = [];
lookupRateBuckets.set(peerOrigin, timestamps);
}
const cutoff = now - LOOKUP_RATE_WINDOW_MS;
while (timestamps.length > 0 && (timestamps[0] ?? Infinity) < cutoff) {
timestamps.shift();
}
if (timestamps.length >= LOOKUP_RATE_MAX) return true;
timestamps.push(now);
return false;
}
// Test-only export — used by federation.userLookup.test.ts to reset between cases.
export function _resetLookupRateBuckets(): void {
lookupRateBuckets.clear();
}
// ─── In-memory rate limiter for the ensure endpoint (per-user) ───────────── // ─── In-memory rate limiter for the ensure endpoint (per-user) ─────────────
const ensureRateBuckets = new Map<string, number[]>(); const ensureRateBuckets = new Map<string, number[]>();
const ENSURE_RATE_WINDOW_MS = 15 * 60_000; // 15 minutes const ENSURE_RATE_WINDOW_MS = 15 * 60_000; // 15 minutes
@@ -1596,6 +1622,98 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
}, },
); );
// ─── POST /api/federation/users/lookup ─────────────────────────────────────
// Server-to-server: resolve a username on this instance to its canonical
// (homeUserId, profile snapshot). Used by another instance to construct a
// friend_request_create event without requiring a federated user account.
//
// Returns 200 with profile snapshot for native users (regardless of the
// user's `discoverable` setting — exact-handle resolution).
// Returns 404 for tombstoned users, replicated stubs, or unknown usernames.
app.post<{ Body: { username?: unknown } }>(
'/api/federation/users/lookup',
{ bodyLimit: 4 * 1024 },
async (request, reply) => {
const db = getDb();
// 1. Verify HMAC (mirror relay endpoint)
const fedHeaders = parseFederationHeaders(request.headers as Record<string, string | string[] | undefined>);
if (!fedHeaders) {
return reply.code(401).send({ error: 'Missing or malformed federation headers', statusCode: 401 });
}
const peer = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, fedHeaders.origin))
.get();
if (!peer || peer.status !== 'active') {
return reply.code(403).send({ error: 'Unknown or inactive peer', statusCode: 403 });
}
if (isLookupRateLimited(peer.origin)) {
return reply.code(429).header('Retry-After', '60').send({ error: 'Rate limit exceeded', statusCode: 429 });
}
const bodyString = JSON.stringify(request.body);
if (!verifyPeerSignature(bodyString, fedHeaders.signature, fedHeaders.timestamp, fedHeaders.nonce, peer)) {
return reply.code(401).send({ error: 'Invalid signature', statusCode: 401 });
}
// 1b. Nonce-based replay protection
if (fedHeaders.nonce) {
if (isNonceDuplicate(peer.origin, fedHeaders.nonce)) {
return reply.code(409).send({ error: 'Duplicate nonce — possible replay', statusCode: 409 });
}
} else if (peer.nonceSupported) {
return reply.code(401).send({ error: 'Nonce required — peer previously supported nonces', statusCode: 401 });
}
// 2. Validate body
const rawUsername = (request.body as { username?: unknown } | null)?.username;
if (typeof rawUsername !== 'string') {
return reply.code(400).send({ error: 'username is required (string)', statusCode: 400 });
}
const username = rawUsername.trim().toLowerCase();
if (!username) {
return reply.code(400).send({ error: 'username is required', statusCode: 400 });
}
// 3. Native-only lookup with isDeleted filter; discoverable is NOT consulted.
const user = db
.select()
.from(schema.users)
.where(
and(
eq(schema.users.username, username),
eq(schema.users.isDeleted, 0),
isNull(schema.users.homeInstance),
),
)
.get();
if (!user) {
return reply.code(404).send({ found: false, code: 'user_not_found' });
}
return reply.code(200).send({
found: true,
user: {
homeUserId: user.id,
username: user.username,
profile: {
displayName: user.displayName,
avatar: user.avatar,
avatarColor: user.avatarColor,
banner: user.banner,
bio: user.bio,
},
},
});
},
);
// ─── POST /api/federation/sync ────────────────────────────────────────────── // ─── POST /api/federation/sync ──────────────────────────────────────────────
// Server-to-server: checkpoint catch-up sync. A peer calls this after downtime // Server-to-server: checkpoint catch-up sync. A peer calls this after downtime
// to retrieve missed DM mutations from the mutation log. // to retrieve missed DM mutations from the mutation log.
@@ -3727,7 +3845,7 @@ function processOwnershipTransferEvent(
* Only updates fields that are currently null/empty on the local row, * Only updates fields that are currently null/empty on the local row,
* so manually-set local values are preserved. * so manually-set local values are preserved.
*/ */
function hydrateReplicatedUserProfile( export function hydrateReplicatedUserProfile(
user: typeof schema.users.$inferSelect, user: typeof schema.users.$inferSelect,
profile: FederationRelayProfileSnapshot | undefined, profile: FederationRelayProfileSnapshot | undefined,
db: ReturnType<typeof getDb>, db: ReturnType<typeof getDb>,
@@ -0,0 +1,251 @@
import { describe, it, expect, beforeEach, 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';
import { signRequest } from '../utils/federationAuth.js';
import { randomUUID } from 'node:crypto';
setWorkerId(1);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Module-level mutable state. Each beforeEach reassigns sqlite/testDb;
// the getDb getter in the mock closes over the current binding.
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
const PEER_ORIGIN = 'https://orbit.test';
const PEER_SECRET = 'a'.repeat(64);
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
vi.mock('../utils/federationAuth.js', async (importActual) => {
const actual = await importActual<typeof import('../utils/federationAuth.js')>();
return { ...actual, getOurOrigin: () => 'https://home.test' };
});
function applyMigrations(db: Database.Database): void {
const dir = path.resolve(__dirname, '../../drizzle');
for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) {
const sqlText = fs.readFileSync(path.join(dir, f), 'utf8');
for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedActivePeer(): void {
testDb.insert(schema.federationPeers).values({
id: 'peer-1',
origin: PEER_ORIGIN,
hmacSecret: PEER_SECRET,
status: 'active',
nonceSupported: 1,
createdAt: Date.now(),
lastSeenAt: Date.now(),
consecutiveFailures: 0,
consecutiveAuthFailures: 0,
} as typeof schema.federationPeers.$inferInsert).run();
}
function seedUser(opts: {
id: string;
username: string;
isDeleted?: 0 | 1;
discoverable?: 0 | 1;
homeInstance?: string | null;
homeUserId?: string | null;
displayName?: string | null;
avatar?: string | null;
avatarColor?: string | null;
banner?: string | null;
bio?: string | null;
}): void {
testDb.insert(schema.users).values({
id: opts.id,
username: opts.username,
displayName: opts.displayName ?? null,
passwordHash: 'x',
status: 'offline',
isAdmin: 0,
isDeleted: opts.isDeleted ?? 0,
discoverable: opts.discoverable ?? 1,
homeInstance: opts.homeInstance ?? null,
homeUserId: opts.homeUserId ?? null,
avatar: opts.avatar ?? null,
avatarColor: opts.avatarColor ?? null,
banner: opts.banner ?? null,
bio: opts.bio ?? null,
createdAt: Date.now(),
}).run();
}
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
const { _resetLookupRateBuckets, federationRoutes } = await import('./federation.js');
_resetLookupRateBuckets();
await app.register(federationRoutes);
await app.ready();
return app;
}
function signedHeaders(body: string): Record<string, string> {
const timestamp = Date.now();
const nonce = randomUUID();
const sig = signRequest(body, PEER_SECRET, timestamp, nonce);
return {
'X-Federation-Origin': PEER_ORIGIN,
'X-Federation-Timestamp': String(timestamp),
'X-Federation-Nonce': nonce,
'X-Federation-Signature': `sha256=${sig}`,
'Content-Type': 'application/json',
};
}
async function lookup(
app: FastifyInstance,
body: object,
headersOverride?: Record<string, string>,
) {
const bodyStr = JSON.stringify(body);
return app.inject({
method: 'POST',
url: '/api/federation/users/lookup',
headers: headersOverride ?? signedHeaders(bodyStr),
payload: bodyStr,
});
}
describe('POST /api/federation/users/lookup', () => {
let app: FastifyInstance;
beforeEach(async () => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
seedActivePeer();
app = await buildApp();
});
it('returns 200 + full profile snapshot for a native user', async () => {
seedUser({ id: 'u1', username: 'alice', displayName: 'Alice', bio: 'hi', avatarColor: '#ff0000' });
const res = await lookup(app, { username: 'alice' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.found).toBe(true);
expect(body.user.homeUserId).toBe('u1');
expect(body.user.username).toBe('alice');
expect(body.user.profile.displayName).toBe('Alice');
expect(body.user.profile.bio).toBe('hi');
expect(body.user.profile.avatarColor).toBe('#ff0000');
});
it('returns 200 for a discoverable=0 user (exact-handle resolution must not regress)', async () => {
seedUser({ id: 'u1', username: 'alice', discoverable: 0 });
const res = await lookup(app, { username: 'alice' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).found).toBe(true);
});
it('returns 404 for a tombstoned user', async () => {
seedUser({ id: 'u1', username: 'alice', isDeleted: 1 });
const res = await lookup(app, { username: 'alice' });
expect(res.statusCode).toBe(404);
expect(JSON.parse(res.body)).toEqual({ found: false, code: 'user_not_found' });
});
it('returns 404 for a replicated stub (homeInstance set)', async () => {
seedUser({
id: 'stub1',
username: 'remote-id@orbit.test',
homeInstance: 'orbit.test',
homeUserId: 'remote-id',
});
const res = await lookup(app, { username: 'remote-id@orbit.test' });
expect(res.statusCode).toBe(404);
expect(JSON.parse(res.body)).toEqual({ found: false, code: 'user_not_found' });
});
it('returns 404 for an unknown username', async () => {
const res = await lookup(app, { username: 'nope' });
expect(res.statusCode).toBe(404);
expect(JSON.parse(res.body)).toEqual({ found: false, code: 'user_not_found' });
});
it('matches lowercase storage from a mixed-case lookup', async () => {
seedUser({ id: 'u1', username: 'alice' });
const res = await lookup(app, { username: 'ALICE' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).found).toBe(true);
});
it('trims whitespace from the lookup username', async () => {
seedUser({ id: 'u1', username: 'alice' });
const res = await lookup(app, { username: ' alice ' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).found).toBe(true);
});
it('returns 400 for a missing username field', async () => {
const res = await lookup(app, {});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toContain('username');
});
it('returns 400 for a non-string username', async () => {
const res = await lookup(app, { username: 42 });
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toContain('username');
});
it('returns 400 for a whitespace-only username', async () => {
const res = await lookup(app, { username: ' ' });
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toContain('username');
});
it('returns 401 when HMAC headers are missing', async () => {
const res = await lookup(app, { username: 'alice' }, { 'Content-Type': 'application/json' });
expect(res.statusCode).toBe(401);
});
it('returns 401 for a bad signature', async () => {
seedUser({ id: 'u1', username: 'alice' });
// Sign a different body — mismatch will fail HMAC verification
const bad = signedHeaders('{"different":"body"}');
const res = await lookup(app, { username: 'alice' }, bad);
expect(res.statusCode).toBe(401);
});
it('returns 403 if the peer is not active', async () => {
testDb
.update(schema.federationPeers)
.set({ status: 'rejected' })
.where(eq(schema.federationPeers.id, 'peer-1'))
.run();
const res = await lookup(app, { username: 'alice' });
expect(res.statusCode).toBe(403);
});
it('returns 429 after 60 lookups in a minute from the same peer', async () => {
seedUser({ id: 'u1', username: 'alice' });
for (let i = 0; i < 60; i++) {
const ok = await lookup(app, { username: 'alice' });
expect(ok.statusCode).toBe(200);
}
const blocked = await lookup(app, { username: 'alice' });
expect(blocked.statusCode).toBe(429);
expect(blocked.headers['retry-after']).toBe('60');
});
});
@@ -0,0 +1,456 @@
import { describe, it, expect, beforeEach, vi, afterEach } 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));
const CALLER_ID = 'caller-id';
let sqlite: Database.Database;
let testDb: ReturnType<typeof drizzle<typeof schema>>;
const sendToUser = vi.fn();
const ensurePeeredMock = vi.fn();
const lookupRemoteUserMock = vi.fn();
const resolveOriginFromHostnameMock = vi.fn();
vi.mock('../db/index.js', () => ({ getDb: () => testDb, getRawDb: () => sqlite, schema }));
vi.mock('../utils/auth.js', () => ({
authenticate: async (req: { userId?: string }) => { req.userId = CALLER_ID; },
}));
vi.mock('../ws/handler.js', () => ({
connectionManager: { sendToUser, sendToAdmins: vi.fn(), sendToDmMembers: vi.fn(), getAllOnlineUserIds: () => [] },
}));
vi.mock('../utils/federationAuth.js', async (importActual) => {
const actual = await importActual<typeof import('../utils/federationAuth.js')>();
return { ...actual, getOurOrigin: () => 'https://home.test' };
});
vi.mock('../utils/federationPeering.js', () => ({
ensurePeered: (...args: unknown[]) => ensurePeeredMock(...args),
racePeering: vi.fn(),
}));
vi.mock('../utils/federationLookup.js', () => ({
lookupRemoteUser: (...args: unknown[]) => lookupRemoteUserMock(...args),
}));
vi.mock('../utils/federationOriginResolve.js', () => ({
resolveOriginFromHostname: (...args: unknown[]) => resolveOriginFromHostnameMock(...args),
}));
function applyMigrations(db: Database.Database): void {
const dir = path.resolve(__dirname, '../../drizzle');
for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) {
const sqlText = fs.readFileSync(path.join(dir, f), 'utf8');
for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedSelf(opts: { homeInstance?: string | null; homeUserId?: string | null } = {}): void {
testDb.insert(schema.users).values({
id: CALLER_ID,
username: 'caller',
displayName: 'Caller',
passwordHash: 'x',
status: 'online',
isAdmin: 0,
homeInstance: opts.homeInstance ?? null,
homeUserId: opts.homeUserId ?? null,
createdAt: Date.now(),
}).run();
// Seed instance_settings with relay enabled so queue/log writes are not silently skipped.
// Use raw exec to avoid the updatedAt NOT NULL constraint (no default in schema).
sqlite.exec(`INSERT OR IGNORE INTO instance_settings (id, federation_relay_enabled, updated_at) VALUES (1, 1, ${Date.now()})`);
}
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
const { socialRoutes } = await import('./social.js');
await app.register(socialRoutes);
await app.ready();
return app;
}
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
sendToUser.mockReset();
ensurePeeredMock.mockReset();
lookupRemoteUserMock.mockReset();
resolveOriginFromHostnameMock.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('POST /api/social/requests — federated branch (happy path)', () => {
it('creates a stub, inserts the request with relayMessageId, queues a relay event', async () => {
seedSelf();
resolveOriginFromHostnameMock.mockReturnValue('https://orbit.test');
ensurePeeredMock.mockResolvedValue({ status: 'active', peerId: 'peer-1' });
lookupRemoteUserMock.mockResolvedValue({
ok: true,
homeUserId: 'remote-alice',
username: 'alice',
profile: {
displayName: 'Alice',
avatar: null,
avatarColor: 'mint',
banner: null,
bio: 'hi',
},
});
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'alice@orbit.test' },
});
// Status and body
expect(res.statusCode).toBe(201);
const body = JSON.parse(res.body) as { success: boolean; requestId: string };
expect(body.success).toBe(true);
expect(typeof body.requestId).toBe('string');
// friend_requests row
const reqRow = testDb.select().from(schema.friendRequests)
.where(eq(schema.friendRequests.id, body.requestId))
.get();
expect(reqRow).toBeTruthy();
expect(reqRow!.fromId).toBe(CALLER_ID);
expect(reqRow!.relayMessageId).toMatch(/^friend_req:/);
// Stub user exists with correct federated identity
const stub = testDb.select().from(schema.users)
.where(eq(schema.users.homeUserId, 'remote-alice'))
.get();
expect(stub).toBeTruthy();
expect(stub!.homeInstance).toBe('orbit.test');
expect(stub!.displayName).toBe('Alice');
expect(stub!.bio).toBe('hi');
// federation_outbox row
const peer = testDb.select({ id: schema.federationPeers.id, origin: schema.federationPeers.origin })
.from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://orbit.test'))
.get();
expect(peer).toBeTruthy();
const outboxRow = testDb.select().from(schema.federationOutbox)
.where(eq(schema.federationOutbox.peerId, peer!.id))
.get();
expect(outboxRow).toBeTruthy();
expect(outboxRow!.eventType).toBe('friend_request_create');
expect(outboxRow!.entityId).toBe(reqRow!.relayMessageId);
// federation_mutation_log row
const mutationRow = testDb.select().from(schema.federationMutationLog)
.where(eq(schema.federationMutationLog.entityId, reqRow!.relayMessageId!))
.get();
expect(mutationRow).toBeTruthy();
// WS broadcast to sender — the 'user' field must carry the TARGET's profile (alice),
// not the sender. This is the federated analogue of the local-branch invariant.
const sentEvent = sendToUser.mock.calls.find(
(c: unknown[]) => (c[1] as { type?: string })?.type === 'friend_request_sent',
);
expect(sentEvent).toBeDefined();
expect(sentEvent![0]).toBe(CALLER_ID);
expect(sentEvent![1].request.id).toBe(body.requestId);
// homeUserId identifies the target; username is the canonical stub form (<homeUserId>@<host>).
expect(sentEvent![1].request.user.homeUserId).toBe('remote-alice');
expect(sentEvent![1].request.user.username).toBe('remote-alice@orbit.test');
});
});
describe('POST /api/social/requests — federated branch (peer status)', () => {
beforeEach(() => {
seedSelf();
resolveOriginFromHostnameMock.mockReturnValue('https://orbit.test');
});
it('returns 403 peer_rejected when ensurePeered returns rejected', async () => {
ensurePeeredMock.mockResolvedValue({ status: 'rejected', error: 'admin must approve' });
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'alice@orbit.test' },
});
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).error).toBe('peer_rejected');
expect(testDb.select().from(schema.friendRequests).all()).toHaveLength(0);
});
it('returns 503 peer_unreachable when ensurePeered returns failed', async () => {
ensurePeeredMock.mockResolvedValue({ status: 'failed', error: 'network' });
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'alice@orbit.test' },
});
expect(res.statusCode).toBe(503);
expect(JSON.parse(res.body).error).toBe('peer_unreachable');
expect(testDb.select().from(schema.friendRequests).all()).toHaveLength(0);
});
it('returns 409 peer_pending_approval when peering is pending and peer row is awaiting_approval', async () => {
ensurePeeredMock.mockResolvedValue({ status: 'pending', error: 'awaiting' });
testDb.insert(schema.federationPeers).values({
id: 'peer-pending-1',
origin: 'https://orbit.test',
hmacSecret: 'secret',
status: 'awaiting_approval',
createdAt: Date.now(),
}).run();
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'alice@orbit.test' },
});
expect(res.statusCode).toBe(409);
expect(JSON.parse(res.body).error).toBe('peer_pending_approval');
});
it('returns 409 peer_pending when peering is pending and no peer row exists (handshake in flight)', async () => {
ensurePeeredMock.mockResolvedValue({ status: 'pending', error: 'in flight' });
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'alice@orbit.test' },
});
expect(res.statusCode).toBe(409);
expect(JSON.parse(res.body).error).toBe('peer_pending');
});
});
describe('POST /api/social/requests — federated branch (lookup failures)', () => {
beforeEach(() => {
seedSelf();
resolveOriginFromHostnameMock.mockReturnValue('https://orbit.test');
ensurePeeredMock.mockResolvedValue({ status: 'active', peerId: 'p1' });
});
it('returns 404 user_not_found when lookup returns not_found', async () => {
lookupRemoteUserMock.mockResolvedValue({ ok: false, reason: 'not_found' });
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'alice@orbit.test' },
});
expect(res.statusCode).toBe(404);
expect(JSON.parse(res.body).error).toBe('user_not_found');
expect(testDb.select().from(schema.friendRequests).all()).toHaveLength(0);
});
it('returns 503 peer_unreachable when lookup returns unreachable', async () => {
lookupRemoteUserMock.mockResolvedValue({ ok: false, reason: 'unreachable' });
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'alice@orbit.test' },
});
expect(res.statusCode).toBe(503);
expect(JSON.parse(res.body).error).toBe('peer_unreachable');
});
it('returns 429 lookup_rate_limited with Retry-After header when lookup returns rate_limited', async () => {
lookupRemoteUserMock.mockResolvedValue({ ok: false, reason: 'rate_limited', retryAfter: 30 });
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'alice@orbit.test' },
});
expect(res.statusCode).toBe(429);
expect(JSON.parse(res.body).error).toBe('lookup_rate_limited');
expect(res.headers['retry-after']).toBe('30');
});
it('returns 400 invalid_target_domain when resolveOriginFromHostname returns null', async () => {
resolveOriginFromHostnameMock.mockReturnValue(null);
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'alice@orbit.test' },
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toBe('invalid_target_domain');
});
});
describe('POST /api/social/requests — federated branch (authority + self-friend + idempotency)', () => {
beforeEach(() => {
resolveOriginFromHostnameMock.mockReturnValue('https://orbit.test');
ensurePeeredMock.mockResolvedValue({ status: 'active', peerId: 'p1' });
lookupRemoteUserMock.mockResolvedValue({
ok: true, homeUserId: 'remote-alice', username: 'alice',
profile: { displayName: 'Alice', avatar: null, avatarColor: 'mint', banner: null, bio: null },
});
});
it('returns 403 not_authoritative_for_sender when caller is a federated user', async () => {
seedSelf({ homeInstance: 'other.test', homeUserId: 'me-elsewhere' });
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'alice@orbit.test' },
});
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).error).toBe('not_authoritative_for_sender');
});
it('passes authority check when sender homeInstance is stored as bare host', async () => {
// homeInstance stored without scheme — normalizeOriginForCompare('home.test') must
// equal normalizeOriginForCompare('https://home.test') (T1 invariant).
seedSelf({ homeInstance: 'home.test', homeUserId: CALLER_ID });
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'alice@orbit.test' },
});
expect(res.statusCode).toBe(201);
});
it('returns 400 cannot_friend_self when looked-up user is canonical-self', async () => {
// The dispatcher routes this as federated because targetDomain ('otherhost') is not
// normalized to our host. resolveOriginFromHostname maps it to our own origin anyway
// (defense-in-depth: misconfigured or spoofed domain). The lookup returns our own
// canonical userId, triggering the self-friend check.
seedSelf();
resolveOriginFromHostnameMock.mockReturnValue('https://home.test');
lookupRemoteUserMock.mockResolvedValue({
ok: true, homeUserId: CALLER_ID, username: 'caller',
profile: { displayName: 'Caller', avatar: null, avatarColor: 'mint', banner: null, bio: null },
});
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'caller@otherhost' },
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toBe('cannot_friend_self');
});
it('returns 409 already_friends if friendship row exists', async () => {
seedSelf();
// Pre-seed stub and friendship
testDb.insert(schema.users).values({
id: 'stub-alice',
username: 'remote-alice@orbit.test',
displayName: 'Alice',
passwordHash: '',
status: 'offline',
isAdmin: 0,
homeInstance: 'orbit.test',
homeUserId: 'remote-alice',
createdAt: Date.now(),
}).run();
testDb.insert(schema.friends).values({
userId: CALLER_ID,
friendId: 'stub-alice',
createdAt: Date.now(),
}).run();
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'alice@orbit.test' },
});
expect(res.statusCode).toBe(409);
expect(JSON.parse(res.body).error).toBe('already_friends');
});
it('returns 200 + existing requestId when same-direction request already pending (idempotent)', async () => {
seedSelf();
// Pre-seed stub and same-direction pending request
testDb.insert(schema.users).values({
id: 'stub-alice',
username: 'remote-alice@orbit.test',
displayName: 'Alice',
passwordHash: '',
status: 'offline',
isAdmin: 0,
homeInstance: 'orbit.test',
homeUserId: 'remote-alice',
createdAt: Date.now(),
}).run();
testDb.insert(schema.friendRequests).values({
id: 'existing-req',
fromId: CALLER_ID,
toId: 'stub-alice',
status: 'pending',
createdAt: Date.now(),
}).run();
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'alice@orbit.test' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body) as { success: boolean; requestId: string };
expect(body.requestId).toBe('existing-req');
// No duplicate row created
const allRequests = testDb.select().from(schema.friendRequests).all();
expect(allRequests).toHaveLength(1);
});
it('returns 409 incoming_request_exists when opposite-direction request already pending', async () => {
seedSelf();
// Pre-seed stub and opposite-direction pending request
testDb.insert(schema.users).values({
id: 'stub-alice',
username: 'remote-alice@orbit.test',
displayName: 'Alice',
passwordHash: '',
status: 'offline',
isAdmin: 0,
homeInstance: 'orbit.test',
homeUserId: 'remote-alice',
createdAt: Date.now(),
}).run();
testDb.insert(schema.friendRequests).values({
id: 'incoming-req',
fromId: 'stub-alice',
toId: CALLER_ID,
status: 'pending',
createdAt: Date.now(),
}).run();
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'alice@orbit.test' },
});
expect(res.statusCode).toBe(409);
const body = JSON.parse(res.body) as { error: string; requestId: string };
expect(body.error).toBe('incoming_request_exists');
expect(body.requestId).toBe('incoming-req');
});
});
+26 -3
View File
@@ -53,9 +53,10 @@ vi.mock('../utils/federationOutbox.js', () => ({
getFriendEventTargets: () => [], getFriendEventTargets: () => [],
})); }));
vi.mock('../utils/federationAuth.js', () => ({ vi.mock('../utils/federationAuth.js', async (importActual) => {
getOurOrigin: () => 'https://local.test', const actual = await importActual<typeof import('../utils/federationAuth.js')>();
})); return { ...actual, getOurOrigin: () => 'https://local.test' };
});
function applyMigrations(db: Database.Database): void { function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle'); const migrationsDir = path.resolve(__dirname, '../../drizzle');
@@ -218,4 +219,26 @@ describe('POST /api/social/requests — case-insensitive username lookup', () =>
expect(res.statusCode).toBe(404); expect(res.statusCode).toBe(404);
expect(JSON.parse(res.body).error).toBe('User not found'); expect(JSON.parse(res.body).error).toBe('User not found');
}); });
it('broadcasts friend_request_sent to the sender on local request creation', async () => {
seedUser({ id: 'u1', username: 'alice' });
const { connectionManager } = await import('../ws/handler.js');
const sendToUser = connectionManager.sendToUser as unknown as ReturnType<typeof vi.fn>;
sendToUser.mockClear();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'alice' },
});
expect(res.statusCode).toBe(201);
const sent = sendToUser.mock.calls.find(c => c[1]?.type === 'friend_request_sent');
expect(sent).toBeDefined();
expect(sent![0]).toBe(CALLER_ID);
// The 'user' field on the sent payload must be the TARGET (alice),
// not the sender — symmetric with how the federated branch builds it.
expect(sent![1].request.user.id).toBe('u1');
expect(sent![1].request.user.username).toBe('alice');
});
}); });
+335 -109
View File
@@ -1,11 +1,15 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { eq, and, or, ne, like, sql, inArray } from 'drizzle-orm'; import { eq, and, or, ne, like, sql, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js'; import { getDb, getRawDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js'; import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js'; import { generateSnowflake } from '../utils/snowflake.js';
import { connectionManager } from '../ws/handler.js'; import { connectionManager } from '../ws/handler.js';
import { appendMutationLog, queueOutboxEvent, buildFriendContextId, getFriendEventTargets } from '../utils/federationOutbox.js'; import { appendMutationLog, queueOutboxEvent, buildFriendContextId, getFriendEventTargets } from '../utils/federationOutbox.js';
import { getOurOrigin } from '../utils/federationAuth.js'; import { getOurOrigin, normalizeOriginForCompare } from '../utils/federationAuth.js';
import { ensurePeered } from '../utils/federationPeering.js';
import { lookupRemoteUser } from '../utils/federationLookup.js';
import { resolveOriginFromHostname } from '../utils/federationOriginResolve.js';
import { resolveOrCreateReplicatedUser, hydrateReplicatedUserProfile } from './federation.js';
import type { FederationRelayEvent, FederationRelayProfileSnapshot } from '@backspace/shared'; import type { FederationRelayEvent, FederationRelayProfileSnapshot } from '@backspace/shared';
import type { import type {
Friend, Friend,
@@ -27,6 +31,308 @@ function buildProfileSnapshot(user: typeof schema.users.$inferSelect): Federatio
}; };
} }
// ─── Local friend request helper ─────────────────────────────────────────────
async function handleLocalFriendRequest(
db: ReturnType<typeof getDb>,
request: FastifyRequest,
reply: FastifyReply,
localUsername: string,
sender: typeof schema.users.$inferSelect,
ourOrigin: string,
): Promise<unknown> {
// Match the canonical-lowercase form used by auth (auth.ts:32, 211, 256).
const lookupUsername = localUsername.toLowerCase();
// Find the target user
const targetUser = db.select().from(schema.users).where(eq(schema.users.username, lookupUsername)).get();
if (!targetUser) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
if (targetUser.id === request.userId) {
return reply.code(400).send({ error: 'You cannot add yourself as a friend', statusCode: 400 });
}
// Check if already friends
const existingFriend = db.select().from(schema.friends).where(or(
and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, targetUser.id)),
and(eq(schema.friends.userId, targetUser.id), eq(schema.friends.friendId, request.userId))
)).get();
if (existingFriend) {
return reply.code(400).send({ error: 'You are already friends with this user', statusCode: 400 });
}
// Check for existing pending request
const existingRequest = db.select().from(schema.friendRequests).where(and(
or(
and(eq(schema.friendRequests.fromId, request.userId), eq(schema.friendRequests.toId, targetUser.id)),
and(eq(schema.friendRequests.fromId, targetUser.id), eq(schema.friendRequests.toId, request.userId))
),
eq(schema.friendRequests.status, 'pending')
)).get();
if (existingRequest) {
return reply.code(400).send({ error: 'A friend request is already pending', statusCode: 400 });
}
// Create the request
const id = generateSnowflake();
const now = Date.now();
db.insert(schema.friendRequests).values({
id,
fromId: request.userId,
toId: targetUser.id,
status: 'pending',
createdAt: now,
}).run();
// Broadcast to the target: they need to know who sent the request (sender profile).
const receivedPayload: FriendRequest = {
id,
fromId: request.userId,
toId: targetUser.id,
status: 'pending',
createdAt: now,
user: sanitizeUser(sender),
};
// Broadcast to the sender's other tabs: they need to know who they added (target profile).
const sentPayload: FriendRequest = {
id,
fromId: request.userId,
toId: targetUser.id,
status: 'pending',
createdAt: now,
user: sanitizeUser(targetUser),
};
connectionManager.sendToUser(targetUser.id, {
type: 'friend_request_received',
request: receivedPayload,
});
connectionManager.sendToUser(request.userId, {
type: 'friend_request_sent',
request: sentPayload,
});
// Federation relay: notify the target user's home instance
const fromIdentity = {
homeUserId: sender.homeUserId || request.userId,
homeInstance: sender.homeInstance || ourOrigin,
};
const toIdentity = {
homeUserId: targetUser.homeUserId || targetUser.id,
homeInstance: targetUser.homeInstance || ourOrigin,
};
const targets = getFriendEventTargets(fromIdentity.homeInstance, toIdentity.homeInstance);
if (targets.length > 0) {
const contextId = buildFriendContextId(fromIdentity.homeUserId, toIdentity.homeUserId);
const entityId = `friend_req:${[fromIdentity.homeUserId, toIdentity.homeUserId].sort().join(':')}:${now}`;
const payload: FederationRelayEvent = {
eventType: 'friend_request_create',
contextType: 'friend',
messageId: entityId,
encryptionVersion: 0,
timestamp: now,
friendship: {
from: fromIdentity,
to: toIdentity,
fromProfile: buildProfileSnapshot(sender),
toProfile: buildProfileSnapshot(targetUser),
status: 'pending',
createdAt: now,
},
};
const payloadStr = JSON.stringify(payload);
appendMutationLog(entityId, contextId, 'friend_request_create', payloadStr, 'friend');
queueOutboxEvent(entityId, contextId, 'friend_request_create', payloadStr, targets, 'friend');
}
return reply.code(201).send({ success: true, requestId: id });
}
// ─── Federated friend request helper ─────────────────────────────────────────
async function handleFederatedFriendRequest(
db: ReturnType<typeof getDb>,
request: FastifyRequest,
reply: FastifyReply,
raw: string,
atIndex: number,
sender: typeof schema.users.$inferSelect,
ourOrigin: string,
): Promise<unknown> {
const baseName = raw.slice(0, atIndex).toLowerCase();
const targetDomain = raw.slice(atIndex + 1).toLowerCase();
// 1. Resolve scheme
const peerOrigin = resolveOriginFromHostname(targetDomain);
if (!peerOrigin) {
return reply.code(400).send({ error: 'invalid_target_domain', statusCode: 400, domain: targetDomain });
}
// 2. ensurePeered — block until 'active', or surface peer status as error
const peering = await ensurePeered(peerOrigin);
if (peering.status === 'rejected') {
return reply.code(403).send({ error: 'peer_rejected', statusCode: 403, domain: targetDomain });
}
if (peering.status === 'failed') {
return reply.code(503).send({ error: 'peer_unreachable', statusCode: 503, domain: targetDomain });
}
if (peering.status === 'pending') {
const peerRow = db.select({ status: schema.federationPeers.status })
.from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, peerOrigin))
.get();
if (peerRow?.status === 'awaiting_approval') {
return reply.code(409).send({ error: 'peer_pending_approval', statusCode: 409, domain: targetDomain });
}
return reply.code(409).send({ error: 'peer_pending', statusCode: 409, domain: targetDomain });
}
// peering.status === 'active' — continue
// 3. Lookup
const lookup = await lookupRemoteUser(peerOrigin, baseName);
if (!lookup.ok) {
if (lookup.reason === 'not_found') {
return reply.code(404).send({ error: 'user_not_found', statusCode: 404, domain: targetDomain, handle: baseName });
}
if (lookup.reason === 'unreachable') {
return reply.code(503).send({ error: 'peer_unreachable', statusCode: 503, domain: targetDomain });
}
if (lookup.reason === 'rate_limited') {
const headers: Record<string, string> = {};
if (lookup.retryAfter) headers['Retry-After'] = String(lookup.retryAfter);
return reply.code(429).headers(headers).send({ error: 'lookup_rate_limited', statusCode: 429 });
}
// Exhaustive — should be unreachable.
return reply.code(500).send({ error: 'unknown_lookup_failure', statusCode: 500 });
}
// 4. Self-friend pre-check
const senderCanonicalId = sender.homeUserId || sender.id;
if (
lookup.homeUserId === senderCanonicalId &&
normalizeOriginForCompare(peerOrigin) === normalizeOriginForCompare(ourOrigin)
) {
return reply.code(400).send({ error: 'cannot_friend_self', statusCode: 400 });
}
// 5. Resolve / hydrate stub
const stub = resolveOrCreateReplicatedUser(lookup.homeUserId, targetDomain, db, { username: lookup.username });
if (!stub) {
// Tombstoned identity — refuse to resurrect.
return reply.code(404).send({ error: 'user_not_found', statusCode: 404, domain: targetDomain, handle: baseName });
}
const stubHydrated = hydrateReplicatedUserProfile(stub, lookup.profile, db);
// 5a. Direction-aware idempotency
const existingRequest = db.select().from(schema.friendRequests).where(and(
or(
and(eq(schema.friendRequests.fromId, sender.id), eq(schema.friendRequests.toId, stubHydrated.id)),
and(eq(schema.friendRequests.fromId, stubHydrated.id), eq(schema.friendRequests.toId, sender.id)),
),
eq(schema.friendRequests.status, 'pending'),
)).get();
if (existingRequest) {
if (existingRequest.fromId === sender.id) {
// Same direction — idempotent return
return reply.code(200).send({ success: true, requestId: existingRequest.id });
} else {
// Opposite direction — incoming request already exists
return reply.code(409).send({
error: 'incoming_request_exists',
statusCode: 409,
requestId: existingRequest.id,
});
}
}
// 5b. Already-friends check
const existingFriend = db.select().from(schema.friends).where(or(
and(eq(schema.friends.userId, sender.id), eq(schema.friends.friendId, stubHydrated.id)),
and(eq(schema.friends.userId, stubHydrated.id), eq(schema.friends.friendId, sender.id)),
)).get();
if (existingFriend) {
return reply.code(409).send({ error: 'already_friends', statusCode: 409 });
}
// 6. Transaction: insert + log + queue outbox
const now = Date.now();
const fromIdentity = {
homeUserId: sender.homeUserId || sender.id,
homeInstance: ourOrigin,
};
const toIdentity = {
homeUserId: stubHydrated.homeUserId!,
homeInstance: peerOrigin,
};
const contextId = buildFriendContextId(fromIdentity.homeUserId, toIdentity.homeUserId);
const entityId = `friend_req:${[fromIdentity.homeUserId, toIdentity.homeUserId].sort().join(':')}:${now}`;
const requestId = generateSnowflake();
const payload: FederationRelayEvent = {
eventType: 'friend_request_create',
contextType: 'friend',
messageId: entityId,
encryptionVersion: 0,
timestamp: now,
friendship: {
from: fromIdentity,
to: toIdentity,
fromProfile: buildProfileSnapshot(sender),
toProfile: {
username: stubHydrated.username,
displayName: lookup.profile.displayName,
avatar: lookup.profile.avatar,
avatarColor: lookup.profile.avatarColor,
banner: lookup.profile.banner,
bio: lookup.profile.bio,
},
status: 'pending',
createdAt: now,
},
};
const payloadStr = JSON.stringify(payload);
const rawDb = getRawDb();
rawDb.transaction(() => {
db.insert(schema.friendRequests).values({
id: requestId,
fromId: sender.id,
toId: stubHydrated.id,
status: 'pending',
createdAt: now,
relayMessageId: entityId,
}).run();
appendMutationLog(entityId, contextId, 'friend_request_create', payloadStr, 'friend');
queueOutboxEvent(entityId, contextId, 'friend_request_create', payloadStr, [peerOrigin], 'friend');
})();
// 7. WS broadcast to sender's other tabs/devices
const requestSnapshot: FriendRequest = {
id: requestId,
fromId: sender.id,
toId: stubHydrated.id,
status: 'pending',
createdAt: now,
user: sanitizeUser(stubHydrated),
};
connectionManager.sendToUser(sender.id, { type: 'friend_request_sent', request: requestSnapshot });
return reply.code(201).send({ success: true, requestId });
}
// ─── Route registration ───────────────────────────────────────────────────────
export async function socialRoutes(app: FastifyInstance): Promise<void> { export async function socialRoutes(app: FastifyInstance): Promise<void> {
// GET /api/social/friends - List all friends // GET /api/social/friends - List all friends
app.get('/api/social/friends', { app.get('/api/social/friends', {
@@ -112,7 +418,7 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send(result); return reply.code(200).send(result);
}); });
// POST /api/social/requests - Send a friend request // POST /api/social/requests - Send a friend request (local or federated)
app.post<{ Body: SendFriendRequest }>('/api/social/requests', { app.post<{ Body: SendFriendRequest }>('/api/social/requests', {
preHandler: authenticate, preHandler: authenticate,
}, async (request, reply) => { }, async (request, reply) => {
@@ -120,116 +426,36 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
const db = getDb(); const db = getDb();
if (!username || typeof username !== 'string') { if (!username || typeof username !== 'string') {
return reply.code(400).send({ error: 'Username is required', statusCode: 400 }); return reply.code(400).send({ error: 'username_required', statusCode: 400 });
} }
// Match the canonical-lowercase form used by auth (auth.ts:32, 211, 256). const raw = username.trim();
const lookupUsername = username.trim().toLowerCase(); if (!raw) return reply.code(400).send({ error: 'username_required', statusCode: 400 });
if (!lookupUsername) {
return reply.code(400).send({ error: 'Username is required', statusCode: 400 }); const sender = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!sender) return reply.code(401).send({ error: 'authenticated user not found', statusCode: 401 });
const ourOrigin = getOurOrigin();
const ourHost = normalizeOriginForCompare(ourOrigin);
// Authority defense — only native users may originate friend_request_create
// outbox events from this instance (spec §5.6). Done before any branching.
const senderHomeNorm = normalizeOriginForCompare(sender.homeInstance);
if (senderHomeNorm && senderHomeNorm !== ourHost) {
return reply.code(403).send({ error: 'not_authoritative_for_sender', statusCode: 403 });
} }
// Find the target user const atIndex = raw.lastIndexOf('@');
const targetUser = db.select().from(schema.users).where(eq(schema.users.username, lookupUsername)).get(); const isFederated =
if (!targetUser) { atIndex > 0 &&
return reply.code(404).send({ error: 'User not found', statusCode: 404 }); atIndex < raw.length - 1 &&
normalizeOriginForCompare(raw.slice(atIndex + 1)) !== ourHost;
if (!isFederated) {
const localUsername = atIndex > 0 ? raw.slice(0, atIndex) : raw;
return handleLocalFriendRequest(db, request, reply, localUsername, sender, ourOrigin);
} }
return handleFederatedFriendRequest(db, request, reply, raw, atIndex, sender, ourOrigin);
if (targetUser.id === request.userId) {
return reply.code(400).send({ error: 'You cannot add yourself as a friend', statusCode: 400 });
}
// Check if already friends
const existingFriend = db.select().from(schema.friends).where(or(
and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, targetUser.id)),
and(eq(schema.friends.userId, targetUser.id), eq(schema.friends.friendId, request.userId))
)).get();
if (existingFriend) {
return reply.code(400).send({ error: 'You are already friends with this user', statusCode: 400 });
}
// Check for existing pending request
const existingRequest = db.select().from(schema.friendRequests).where(and(
or(
and(eq(schema.friendRequests.fromId, request.userId), eq(schema.friendRequests.toId, targetUser.id)),
and(eq(schema.friendRequests.fromId, targetUser.id), eq(schema.friendRequests.toId, request.userId))
),
eq(schema.friendRequests.status, 'pending')
)).get();
if (existingRequest) {
return reply.code(400).send({ error: 'A friend request is already pending', statusCode: 400 });
}
// Create the request
const id = generateSnowflake();
const now = Date.now();
db.insert(schema.friendRequests).values({
id,
fromId: request.userId,
toId: targetUser.id,
status: 'pending',
createdAt: now,
}).run();
// Get the sender user for the WS event
const senderUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
// Broadcast friend_request_received to the target user
const friendRequestPayload: FriendRequest = {
id,
fromId: request.userId,
toId: targetUser.id,
status: 'pending',
createdAt: now,
user: senderUser ? sanitizeUser(senderUser) : undefined,
};
connectionManager.sendToUser(targetUser.id, {
type: 'friend_request_received',
request: friendRequestPayload,
});
// Federation relay: notify the target user's home instance
const domainOrigin = getOurOrigin();
const fromIdentity = {
homeUserId: senderUser?.homeUserId || request.userId,
homeInstance: senderUser?.homeInstance || domainOrigin,
};
const toIdentity = {
homeUserId: targetUser.homeUserId || targetUser.id,
homeInstance: targetUser.homeInstance || domainOrigin,
};
const targets = getFriendEventTargets(fromIdentity.homeInstance, toIdentity.homeInstance);
if (targets.length > 0) {
const contextId = buildFriendContextId(fromIdentity.homeUserId, toIdentity.homeUserId);
const entityId = `friend_req:${[fromIdentity.homeUserId, toIdentity.homeUserId].sort().join(':')}:${now}`;
const payload: FederationRelayEvent = {
eventType: 'friend_request_create',
contextType: 'friend',
messageId: entityId,
encryptionVersion: 0,
timestamp: now,
friendship: {
from: fromIdentity,
to: toIdentity,
fromProfile: senderUser ? buildProfileSnapshot(senderUser) : undefined,
toProfile: buildProfileSnapshot(targetUser),
status: 'pending',
createdAt: now,
},
};
const payloadStr = JSON.stringify(payload);
appendMutationLog(entityId, contextId, 'friend_request_create', payloadStr, 'friend');
queueOutboxEvent(entityId, contextId, 'friend_request_create', payloadStr, targets, 'friend');
}
return reply.code(201).send({ success: true, requestId: id });
}); });
// PATCH /api/social/requests/:id - Accept/Decline a friend request // PATCH /api/social/requests/:id - Accept/Decline a friend request
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { verifyPeerSignature, signRequest, ROTATION_GRACE_PERIOD_MS } from './federationAuth.js'; import { verifyPeerSignature, signRequest, ROTATION_GRACE_PERIOD_MS, normalizeOriginForCompare } from './federationAuth.js';
describe('verifyPeerSignature', () => { describe('verifyPeerSignature', () => {
const primarySecret = 'a'.repeat(64); const primarySecret = 'a'.repeat(64);
@@ -78,3 +78,34 @@ describe('verifyPeerSignature', () => {
expect(verifyPeerSignature(body, sig, timestamp, nonce, peer)).toBe(false); expect(verifyPeerSignature(body, sig, timestamp, nonce, peer)).toBe(false);
}); });
}); });
describe('normalizeOriginForCompare', () => {
it('canonicalizes a bare host', () => {
expect(normalizeOriginForCompare('nova.ddns.net')).toBe('nova.ddns.net');
});
it('strips https:// scheme', () => {
expect(normalizeOriginForCompare('https://nova.ddns.net')).toBe('nova.ddns.net');
});
it('strips http:// scheme', () => {
expect(normalizeOriginForCompare('http://localhost:3005')).toBe('localhost:3005');
});
it('strips trailing slash', () => {
expect(normalizeOriginForCompare('https://nova.ddns.net/')).toBe('nova.ddns.net');
});
it('lowercases the host', () => {
expect(normalizeOriginForCompare('HTTPS://Nova.DDNS.net')).toBe('nova.ddns.net');
});
it('returns null for null input', () => {
expect(normalizeOriginForCompare(null)).toBeNull();
});
it('returns null for undefined input', () => {
expect(normalizeOriginForCompare(undefined)).toBeNull();
});
it('returns null for empty string', () => {
expect(normalizeOriginForCompare('')).toBeNull();
});
it('treats bare and full-URL forms as equal', () => {
expect(normalizeOriginForCompare('nova.ddns.net'))
.toBe(normalizeOriginForCompare('https://nova.ddns.net'));
});
});
@@ -182,3 +182,31 @@ export function getOurOrigin(): string {
} }
return `http://localhost:${config.port}`; return `http://localhost:${config.port}`;
} }
/**
* Canonicalize a homeInstance / origin value for comparison.
*
* The homeInstance column is stored in two shapes depending on the code path
* that wrote it:
* - `auth.ts` registration writes the bare host the client sent (e.g. `nova.ddns.net`).
* - `resolveOrCreateReplicatedUser` writes the bare host (`extractDomain(...)`).
* - `getOurOrigin()` returns the full URL (`https://nova.ddns.net`).
*
* All federation authority / self-friend comparisons must route through this
* helper to avoid false-fires across the dual storage convention. Returns the
* lowercased host (with optional :port), no scheme, no trailing slash.
*
* NOTE: A federation-wide audit + canonical-storage migration is tracked
* separately. This helper papers over the inconsistency at comparison sites.
*/
export function normalizeOriginForCompare(value: string | null | undefined): string | null {
if (!value) return null;
let s = value.trim();
if (!s) return null;
// Strip scheme if present
s = s.replace(/^https?:\/\//i, '');
// Strip trailing slashes
s = s.replace(/\/+$/, '');
if (!s) return null;
return s.toLowerCase();
}
@@ -0,0 +1,218 @@
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 { setWorkerId } from '../utils/snowflake.js';
setWorkerId(1);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
const PEER_ORIGIN = 'https://orbit.test';
const PEER_SECRET = 'a'.repeat(64);
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
vi.mock('./federationAuth.js', async (importActual) => {
const actual = await importActual<typeof import('./federationAuth.js')>();
return { ...actual, getOurOrigin: () => 'https://home.test' };
});
function applyMigrations(db: Database.Database): void {
const dir = path.resolve(__dirname, '../../drizzle');
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith('.sql')).sort()) {
const sqlText = fs.readFileSync(path.join(dir, f), 'utf8');
for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedActivePeer(): void {
testDb
.insert(schema.federationPeers)
.values({
id: 'peer-1',
origin: PEER_ORIGIN,
hmacSecret: PEER_SECRET,
status: 'active',
nonceSupported: 1,
createdAt: Date.now(),
lastSeenAt: Date.now(),
consecutiveFailures: 0,
consecutiveAuthFailures: 0,
} as typeof schema.federationPeers.$inferInsert)
.run();
}
const VALID_RESPONSE_BODY = {
found: true,
user: {
homeUserId: 'remote-uid-1',
username: 'bob',
profile: {
displayName: 'Bob',
avatar: null,
avatarColor: null,
banner: null,
bio: 'hello from orbit',
},
},
};
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
seedActivePeer();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('lookupRemoteUser', () => {
it('1. returns ok:true with homeUserId/username/profile on HTTP 200 + valid body', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: () => null },
json: async () => VALID_RESPONSE_BODY,
}));
const { lookupRemoteUser } = await import('./federationLookup.js');
const result = await lookupRemoteUser(PEER_ORIGIN, 'bob');
expect(result).toEqual({
ok: true,
homeUserId: 'remote-uid-1',
username: 'bob',
profile: {
displayName: 'Bob',
avatar: null,
avatarColor: null,
banner: null,
bio: 'hello from orbit',
},
});
});
it('2. returns ok:false reason:not_found on HTTP 404', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 404,
headers: { get: () => null },
}));
const { lookupRemoteUser } = await import('./federationLookup.js');
const result = await lookupRemoteUser(PEER_ORIGIN, 'nobody');
expect(result).toEqual({ ok: false, reason: 'not_found' });
});
it('3. returns ok:false reason:rate_limited with retryAfter on HTTP 429', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 429,
headers: { get: (name: string) => (name === 'Retry-After' ? '60' : null) },
}));
const { lookupRemoteUser } = await import('./federationLookup.js');
const result = await lookupRemoteUser(PEER_ORIGIN, 'bob');
expect(result).toEqual({ ok: false, reason: 'rate_limited', retryAfter: 60 });
});
it('4. returns ok:false reason:unreachable on network error (TypeError)', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')));
const { lookupRemoteUser } = await import('./federationLookup.js');
const result = await lookupRemoteUser(PEER_ORIGIN, 'bob');
expect(result).toEqual({ ok: false, reason: 'unreachable' });
});
it('5. returns ok:false reason:unreachable on AbortError (timeout)', async () => {
const abortError = new DOMException('The operation was aborted.', 'AbortError');
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(abortError));
const { lookupRemoteUser } = await import('./federationLookup.js');
const result = await lookupRemoteUser(PEER_ORIGIN, 'bob');
expect(result).toEqual({ ok: false, reason: 'unreachable' });
});
it('6. throws when no peer record exists in federation_peers', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: () => null },
json: async () => VALID_RESPONSE_BODY,
}));
const { lookupRemoteUser } = await import('./federationLookup.js');
await expect(lookupRemoteUser('https://unknown.test', 'bob')).rejects.toThrow(
'lookupRemoteUser: no peer record for https://unknown.test',
);
});
it('7. signs the request with correct HMAC headers', async () => {
let capturedInit: RequestInit | undefined;
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation((_url: string, init: RequestInit) => {
capturedInit = init;
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => null },
json: async () => VALID_RESPONSE_BODY,
});
}),
);
const { lookupRemoteUser } = await import('./federationLookup.js');
await lookupRemoteUser(PEER_ORIGIN, 'bob');
expect(capturedInit).toBeDefined();
const headers = capturedInit!.headers as Record<string, string>;
// URL
const fetchMock = (globalThis.fetch as ReturnType<typeof vi.fn>);
expect(fetchMock).toHaveBeenCalledWith(
`${PEER_ORIGIN}/api/federation/users/lookup`,
expect.anything(),
);
// Required HMAC headers
expect(headers['X-Federation-Origin']).toBe('https://home.test');
expect(headers['X-Federation-Signature']).toMatch(/^sha256=[0-9a-f]{64}$/);
expect(headers['X-Federation-Nonce']).toBeTruthy();
expect(headers['X-Federation-Timestamp']).toMatch(/^\d+$/);
});
it('8. throws on malformed 200 body', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: () => null },
json: async () => ({ found: false, code: 'user_not_found' }),
}));
const { lookupRemoteUser } = await import('./federationLookup.js');
await expect(lookupRemoteUser(PEER_ORIGIN, 'bob')).rejects.toThrow(
`lookupRemoteUser: peer ${PEER_ORIGIN} returned malformed body`,
);
});
});
@@ -0,0 +1,77 @@
import { eq } from 'drizzle-orm';
import { getDb } from '../db/index.js';
import * as schema from '../db/schema.js';
import { buildFederationHeaders, getOurOrigin } from './federationAuth.js';
import type { FederationUserLookupProfile, FederationUserLookupResponse } from '@backspace/shared';
const LOOKUP_TIMEOUT_MS = 10_000;
export type LookupResult =
| { ok: true; homeUserId: string; username: string; profile: FederationUserLookupProfile }
| { ok: false; reason: 'not_found' }
| { ok: false; reason: 'unreachable' }
| { ok: false; reason: 'rate_limited'; retryAfter?: number };
/**
* Look up a username on a remote peer instance.
*
* - Looks up the peer's HMAC secret from the local federation_peers table.
* - Throws if the peer record is missing — caller must ensurePeered first.
* - Wraps fetch in a 10s timeout; network/timeout/AbortError → unreachable.
* - HTTP 404 → not_found; 429 → rate_limited (with retryAfter); 200 + valid body → ok;
* anything else (5xx, unexpected 4xx, malformed body) → throws (logged at call site).
*/
export async function lookupRemoteUser(peerOrigin: string, username: string): Promise<LookupResult> {
const db = getDb();
const peer = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, peerOrigin))
.get();
if (!peer) {
throw new Error(`lookupRemoteUser: no peer record for ${peerOrigin}`);
}
const body = JSON.stringify({ username });
const headers = buildFederationHeaders(body, peer.hmacSecret, getOurOrigin());
let response: Response;
try {
response = await fetch(`${peerOrigin}/api/federation/users/lookup`, {
method: 'POST',
headers,
body,
signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS),
});
} catch {
// Network error, timeout, AbortError — all unreachable.
return { ok: false, reason: 'unreachable' };
}
if (response.status === 404) {
return { ok: false, reason: 'not_found' };
}
if (response.status === 429) {
const raw = Number(response.headers.get('Retry-After') ?? '60');
const retryAfter = Number.isFinite(raw) ? raw : 60;
return { ok: false, reason: 'rate_limited', retryAfter };
}
if (!response.ok) {
throw new Error(`lookupRemoteUser: peer ${peerOrigin} returned HTTP ${response.status}`);
}
const json = (await response.json()) as FederationUserLookupResponse;
if (!json || json.found !== true || !json.user || typeof json.user.homeUserId !== 'string') {
throw new Error(`lookupRemoteUser: peer ${peerOrigin} returned malformed body`);
}
return {
ok: true,
homeUserId: json.user.homeUserId,
username: json.user.username,
profile: json.user.profile,
};
}
@@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach, 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';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
let sqlite: Database.Database;
let testDb: ReturnType<typeof drizzle<typeof schema>>;
let mockOurOrigin = 'https://home.test';
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
vi.mock('./federationAuth.js', async (importActual) => {
const actual = await importActual<typeof import('./federationAuth.js')>();
return { ...actual, getOurOrigin: () => mockOurOrigin };
});
function applyMigrations(db: Database.Database): void {
const dir = path.resolve(__dirname, '../../drizzle');
for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) {
const sqlText = fs.readFileSync(path.join(dir, f), 'utf8');
for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedPeer(origin: string): void {
testDb.insert(schema.federationPeers).values({
id: `peer-${origin}`,
origin,
hmacSecret: 'a'.repeat(64),
status: 'active',
nonceSupported: 1,
createdAt: Date.now(),
consecutiveFailures: 0,
consecutiveAuthFailures: 0,
} as typeof schema.federationPeers.$inferInsert).run();
}
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
mockOurOrigin = 'https://home.test';
});
describe('resolveOriginFromHostname', () => {
it('returns stored peer origin on exact host match', async () => {
seedPeer('https://orbit.test');
const { resolveOriginFromHostname } = await import('./federationOriginResolve.js');
expect(resolveOriginFromHostname('orbit.test')).toBe('https://orbit.test');
});
it('matches peer origin case-insensitively', async () => {
seedPeer('https://orbit.test');
const { resolveOriginFromHostname } = await import('./federationOriginResolve.js');
expect(resolveOriginFromHostname('ORBIT.TEST')).toBe('https://orbit.test');
});
it('mirrors https scheme when no peer matches', async () => {
mockOurOrigin = 'https://home.test';
const { resolveOriginFromHostname } = await import('./federationOriginResolve.js');
expect(resolveOriginFromHostname('newpeer.example')).toBe('https://newpeer.example');
});
it('mirrors http scheme for localhost targets', async () => {
mockOurOrigin = 'http://localhost:3005';
const { resolveOriginFromHostname } = await import('./federationOriginResolve.js');
expect(resolveOriginFromHostname('localhost:3006')).toBe('http://localhost:3006');
});
it('returns null when validateOrigin rejects http for non-localhost', async () => {
mockOurOrigin = 'http://localhost:3005';
const { resolveOriginFromHostname } = await import('./federationOriginResolve.js');
expect(resolveOriginFromHostname('newpeer.example')).toBeNull();
});
it('returns null for empty input', async () => {
const { resolveOriginFromHostname } = await import('./federationOriginResolve.js');
expect(resolveOriginFromHostname('')).toBeNull();
});
});
@@ -0,0 +1,51 @@
import { getDb } from '../db/index.js';
import * as schema from '../db/schema.js';
import { getOurOrigin } from './federationAuth.js';
import { validateOrigin } from '../routes/federation.js';
/**
* Resolve a typed hostname (e.g., the part after `@` in `alice@orbit.test`)
* into a full peer origin URL suitable for ensurePeered() / fetch().
*
* Resolution order:
* 1. If a federation_peers row exists whose URL host matches (case-insensitive),
* return that peer's stored origin verbatim. (Authoritative for any peer the
* admin has explicitly configured.)
* 2. Otherwise, mirror getOurOrigin()'s scheme:
* - https://... → https://${hostname}
* - http://... → http://${hostname} (covers dev: localhost:3006)
* Validate via validateOrigin (which rejects http for non-localhost).
*
* Returns null if the result fails validation (e.g., http for a public domain
* when our scheme is http — caller should surface as 'invalid target').
*
* Stale-scheme edge case: if a stored peer row points at the wrong scheme
* (peer migrated http↔https since the row was written), ensurePeered will
* surface a connectivity failure via the standard 'unreachable' path. Scheme
* migration of an existing peer is an admin operation outside this code's
* scope (delete + re-peer).
*/
export function resolveOriginFromHostname(hostnameOrHostPort: string): string | null {
if (!hostnameOrHostPort) return null;
const target = hostnameOrHostPort.trim().toLowerCase();
if (!target) return null;
const db = getDb();
const peers = db
.select({ origin: schema.federationPeers.origin })
.from(schema.federationPeers)
.all();
for (const p of peers) {
try {
const u = new URL(p.origin);
if (u.host.toLowerCase() === target) return p.origin;
} catch {
// skip malformed origin
}
}
const ourScheme = getOurOrigin().startsWith('https://') ? 'https://' : 'http://';
const candidate = `${ourScheme}${target}`;
return validateOrigin(candidate);
}
@@ -133,3 +133,21 @@ describe('queueReadStateRelay — mutation log capture', () => {
expect(rows[0]?.contextType).toBe('dm'); expect(rows[0]?.contextType).toBe('dm');
}); });
}); });
describe('buildFriendContextId — cross-instance determinism', () => {
it('produces the same value regardless of argument order', async () => {
const { buildFriendContextId } = await import('./federationOutbox.js');
expect(buildFriendContextId('alice', 'bob')).toBe(buildFriendContextId('bob', 'alice'));
});
it('produces the same value on home and on orbit for the same canonical pair', async () => {
// home computes: buildFriendContextId(myHomeUserId, theirHomeUserId)
// orbit computes: buildFriendContextId(theirHomeUserId, myHomeUserId)
// Both must equal — initial-sync backfill depends on this invariant.
const { buildFriendContextId } = await import('./federationOutbox.js');
const home = buildFriendContextId('home-id-1', 'orbit-id-2');
const orbit = buildFriendContextId('orbit-id-2', 'home-id-1');
expect(home).toBe(orbit);
expect(home).toBe('friend:home-id-1:orbit-id-2');
});
});
@@ -0,0 +1,107 @@
import { describe, it, expect, beforeEach, 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 '../utils/snowflake.js';
setWorkerId(1);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
let sqlite: Database.Database;
let testDb: ReturnType<typeof drizzle<typeof schema>>;
const sendToUser = vi.fn();
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
vi.mock('../ws/handler.js', () => ({
connectionManager: { sendToUser },
}));
function applyMigrations(db: Database.Database): void {
const dir = path.resolve(__dirname, '../../drizzle');
for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) {
const sqlText = fs.readFileSync(path.join(dir, f), 'utf8');
for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
sendToUser.mockReset();
});
describe('rollbackFriendRequestCreate', () => {
it('deletes the matching friend_requests row and emits friend_request_relay_failed to sender', async () => {
testDb.insert(schema.users).values([
{ id: 'sender', username: 'bob', passwordHash: 'x', status: 'offline', isAdmin: 0, createdAt: Date.now() },
{ id: 'stub', username: 'alice@orbit.test', passwordHash: '!federation-replicated',
status: 'offline', isAdmin: 0, homeInstance: 'orbit.test', homeUserId: 'remote-1', createdAt: Date.now() },
] as typeof schema.users.$inferInsert[]).run();
testDb.insert(schema.friendRequests).values({
id: 'req-1',
fromId: 'sender',
toId: 'stub',
status: 'pending',
createdAt: Date.now(),
relayMessageId: 'friend_req:remote-1:sender:1234',
} as typeof schema.friendRequests.$inferInsert).run();
const { rollbackFriendRequestCreate } = await import('./federationRollback.js');
rollbackFriendRequestCreate('friend_req:remote-1:sender:1234', 'recipient_not_found');
const remaining = testDb.select().from(schema.friendRequests).where(eq(schema.friendRequests.id, 'req-1')).get();
expect(remaining).toBeUndefined();
expect(sendToUser).toHaveBeenCalledOnce();
const [userId, event] = sendToUser.mock.calls[0]!;
expect(userId).toBe('sender');
expect(event.type).toBe('friend_request_relay_failed');
expect(event.requestId).toBe('req-1');
expect(event.reason).toBe('user_not_found');
expect(event.targetHandle).toBe('alice@orbit.test');
expect(typeof event.message).toBe('string');
});
it('is idempotent: no-op if no row matches the messageId', async () => {
const { rollbackFriendRequestCreate } = await import('./federationRollback.js');
expect(() => rollbackFriendRequestCreate('no-such-msg', 'recipient_not_found')).not.toThrow();
expect(sendToUser).not.toHaveBeenCalled();
});
it('maps unknown reasons to peer_rejected', async () => {
testDb.insert(schema.users).values([
{ id: 'sender', username: 'bob', passwordHash: 'x', status: 'offline', isAdmin: 0, createdAt: Date.now() },
{ id: 'stub', username: 'alice@orbit.test', passwordHash: '!federation-replicated',
status: 'offline', isAdmin: 0, homeInstance: 'orbit.test', homeUserId: 'remote-1', createdAt: Date.now() },
] as typeof schema.users.$inferInsert[]).run();
testDb.insert(schema.friendRequests).values({
id: 'req-2',
fromId: 'sender',
toId: 'stub',
status: 'pending',
createdAt: Date.now(),
relayMessageId: 'msg-x',
} as typeof schema.friendRequests.$inferInsert).run();
const { rollbackFriendRequestCreate } = await import('./federationRollback.js');
rollbackFriendRequestCreate('msg-x', 'attribution_mismatch');
expect(sendToUser).toHaveBeenCalledOnce();
const event = sendToUser.mock.calls[0]![1];
expect(event.reason).toBe('peer_rejected');
});
});
@@ -0,0 +1,41 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
registerPermanentFailureCallback,
invokePermanentFailureCallback,
_resetCallbacks,
} from './federationRollback.js';
describe('permanent-failure callback registry', () => {
beforeEach(() => _resetCallbacks());
it('invokes the registered callback when called by eventType', () => {
const cb = vi.fn();
registerPermanentFailureCallback('friend_request_create', cb);
invokePermanentFailureCallback('friend_request_create', 'msg-123', 'recipient_not_found');
expect(cb).toHaveBeenCalledWith('msg-123', 'recipient_not_found');
});
it('is a no-op for unregistered event types', () => {
expect(() =>
invokePermanentFailureCallback('unknown_event_type', 'msg-1', 'whatever')
).not.toThrow();
});
it('replaces a previously-registered callback for the same eventType', () => {
const old = vi.fn();
const fresh = vi.fn();
registerPermanentFailureCallback('e1', old);
registerPermanentFailureCallback('e1', fresh);
invokePermanentFailureCallback('e1', 'msg-9', 'reason');
expect(old).not.toHaveBeenCalled();
expect(fresh).toHaveBeenCalledWith('msg-9', 'reason');
});
it('swallows errors thrown by the callback (logs but does not throw)', () => {
registerPermanentFailureCallback('boom', () => { throw new Error('test'); });
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => invokePermanentFailureCallback('boom', 'msg-x', 'r')).not.toThrow();
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
});
@@ -0,0 +1,95 @@
/**
* Permanent-failure callback registry for outbox events.
*
* When the federation worker observes a receiver-acknowledged terminal
* rejection (4xx with a recognized reason like 'recipient_not_found'),
* it invokes the registered callback for the eventType so the originating
* instance can roll back any local state created at queue time.
*
* Callbacks are NEVER invoked on transient failures (5xx, network errors,
* retry exhaustion). Only on receiver-acknowledged terminal rejections.
*
* Errors thrown by callbacks are logged but not re-thrown — rollback failure
* must not prevent the outbox entry from being deleted.
*/
import { getDb } from '../db/index.js';
import * as schema from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { connectionManager } from '../ws/handler.js';
type PermanentFailureCallback = (messageId: string, reason: string) => void;
const callbacks = new Map<string, PermanentFailureCallback>();
export function registerPermanentFailureCallback(eventType: string, cb: PermanentFailureCallback): void {
callbacks.set(eventType, cb);
}
export function invokePermanentFailureCallback(eventType: string, messageId: string, reason: string): void {
const cb = callbacks.get(eventType);
if (!cb) return;
try {
cb(messageId, reason);
} catch (err) {
console.error(
`[federation-rollback] callback for ${eventType} (msg=${messageId}, reason=${reason}) threw:`,
err,
);
}
}
/** Test-only: clear the registry between tests. */
export function _resetCallbacks(): void {
callbacks.clear();
}
/**
* Rollback handler for 'friend_request_create' outbox events.
*
* Deletes the pending friend request row whose relayMessageId matches the
* failed outbox message, then notifies the sender via WebSocket so the client
* can surface an appropriate error toast.
*/
export function rollbackFriendRequestCreate(messageId: string, receiverReason: string): void {
const db = getDb();
const row = db
.select()
.from(schema.friendRequests)
.where(eq(schema.friendRequests.relayMessageId, messageId))
.get();
if (!row) return; // Idempotent — no row to roll back.
// Look up recipient handle for the toast text BEFORE deleting.
const recipient = db
.select({ username: schema.users.username })
.from(schema.users)
.where(eq(schema.users.id, row.toId))
.get();
const targetHandle = recipient?.username ?? 'unknown';
db.delete(schema.friendRequests).where(eq(schema.friendRequests.id, row.id)).run();
// Reason mapping: receiver-side reason → client-facing reason.
// 'recipient_not_found' → 'user_not_found' (the looked-up identity vanished)
// anything else → 'peer_rejected' (catch-all)
const reason: 'user_not_found' | 'peer_rejected' =
receiverReason === 'recipient_not_found' ? 'user_not_found' : 'peer_rejected';
const message =
reason === 'user_not_found'
? `User ${targetHandle} no longer exists on the remote instance.`
: `Friend request to ${targetHandle} was rejected by the remote instance.`;
connectionManager.sendToUser(row.fromId, {
type: 'friend_request_relay_failed',
requestId: row.id,
reason,
message,
targetHandle,
});
}
// Register the callback at module-load time so the worker invokes it.
registerPermanentFailureCallback('friend_request_create', rollbackFriendRequestCreate);
@@ -64,6 +64,13 @@ vi.mock('../utils/federationAuthFailure.js', () => ({
AUTH_FAILURE_THRESHOLD: 5, AUTH_FAILURE_THRESHOLD: 5,
})); }));
const invokeRollbackMock = vi.fn();
vi.mock('./federationRollback.js', () => ({
invokePermanentFailureCallback: invokeRollbackMock,
registerPermanentFailureCallback: vi.fn(),
_resetCallbacks: vi.fn(),
}));
function applyMigrations(db: Database.Database): void { function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle'); const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
@@ -84,10 +91,10 @@ function seedPeer(id: string): void {
}).run(); }).run();
} }
function seedOutboxEntry(id: string, peerId: string, entityId: string): void { function seedOutboxEntry(id: string, peerId: string, entityId: string, eventType = 'create'): void {
testDb.insert(schema.federationOutbox).values({ testDb.insert(schema.federationOutbox).values({
id, peerId, contextId: 'ch-1', entityId, id, peerId, contextId: 'ch-1', entityId,
contextType: 'dm', eventType: 'create', payload: JSON.stringify({ contextType: 'dm', eventType, payload: JSON.stringify({
message: { userId: 'u', homeUserId: 'u', homeInstance: 'test.example', content: 'hi', replyToId: null, editedAt: null, createdAt: Date.now() }, message: { userId: 'u', homeUserId: 'u', homeInstance: 'test.example', content: 'hi', replyToId: null, editedAt: null, createdAt: Date.now() },
}), }),
encryptionVersion: 0, attempts: 0, nextRetryAt: Date.now() - 1000, encryptionVersion: 0, attempts: 0, nextRetryAt: Date.now() - 1000,
@@ -288,3 +295,133 @@ describe('federatedCallSentinel', () => {
); );
}); });
}); });
// ─── Terminal rejection reasons + rollback invocation ────────────────────────
describe('outbox worker — terminal rejection reasons + rollback invocation', () => {
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
vi.restoreAllMocks();
invokeRollbackMock.mockReset();
});
afterEach(() => {
sqlite.close();
});
it('recipient_not_found for friend_request_create is terminal AND invokes rollback', async () => {
seedPeer('peer-r1');
seedOutboxEntry('entry-r1', 'peer-r1', 'msg-1', 'friend_request_create');
vi.spyOn(globalThis, 'fetch').mockImplementation(async () =>
new Response(JSON.stringify({
accepted: [],
rejected: [{ messageId: 'msg-1', reason: 'recipient_not_found' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
const workerModule = await import('./federationWorker.js');
const processOutboxTick = (workerModule as { processOutboxTick?: () => Promise<void> }).processOutboxTick!;
await processOutboxTick();
const remaining = testDb.select().from(schema.federationOutbox)
.where(eq(schema.federationOutbox.id, 'entry-r1')).get();
expect(remaining).toBeUndefined();
expect(invokeRollbackMock).toHaveBeenCalledTimes(1);
expect(invokeRollbackMock).toHaveBeenCalledWith('friend_request_create', 'msg-1', 'recipient_not_found');
});
it('recipient_not_found for dm_message_create: row deleted, rollback still invoked (no-op inside registry)', async () => {
seedPeer('peer-r2');
seedOutboxEntry('entry-r2', 'peer-r2', 'msg-2', 'dm_message_create');
vi.spyOn(globalThis, 'fetch').mockImplementation(async () =>
new Response(JSON.stringify({
accepted: [],
rejected: [{ messageId: 'msg-2', reason: 'recipient_not_found' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
const workerModule = await import('./federationWorker.js');
const processOutboxTick = (workerModule as { processOutboxTick?: () => Promise<void> }).processOutboxTick!;
await processOutboxTick();
const remaining = testDb.select().from(schema.federationOutbox)
.where(eq(schema.federationOutbox.id, 'entry-r2')).get();
expect(remaining).toBeUndefined();
// Worker always calls invoke; whether a callback is registered is the registry's concern
expect(invokeRollbackMock).toHaveBeenCalledTimes(1);
expect(invokeRollbackMock).toHaveBeenCalledWith('dm_message_create', 'msg-2', 'recipient_not_found');
});
it('duplicate rejection is terminal but does NOT invoke rollback callback', async () => {
seedPeer('peer-r3');
seedOutboxEntry('entry-r3', 'peer-r3', 'msg-3', 'friend_request_create');
vi.spyOn(globalThis, 'fetch').mockImplementation(async () =>
new Response(JSON.stringify({
accepted: [],
rejected: [{ messageId: 'msg-3', reason: 'duplicate' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
const workerModule = await import('./federationWorker.js');
const processOutboxTick = (workerModule as { processOutboxTick?: () => Promise<void> }).processOutboxTick!;
await processOutboxTick();
const remaining = testDb.select().from(schema.federationOutbox)
.where(eq(schema.federationOutbox.id, 'entry-r3')).get();
expect(remaining).toBeUndefined();
expect(invokeRollbackMock).not.toHaveBeenCalled();
});
it('attribution_mismatch is terminal AND invokes rollback', async () => {
seedPeer('peer-r4');
seedOutboxEntry('entry-r4', 'peer-r4', 'msg-4', 'friend_request_create');
vi.spyOn(globalThis, 'fetch').mockImplementation(async () =>
new Response(JSON.stringify({
accepted: [],
rejected: [{ messageId: 'msg-4', reason: 'attribution_mismatch' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
const workerModule = await import('./federationWorker.js');
const processOutboxTick = (workerModule as { processOutboxTick?: () => Promise<void> }).processOutboxTick!;
await processOutboxTick();
const remaining = testDb.select().from(schema.federationOutbox)
.where(eq(schema.federationOutbox.id, 'entry-r4')).get();
expect(remaining).toBeUndefined();
expect(invokeRollbackMock).toHaveBeenCalledTimes(1);
expect(invokeRollbackMock).toHaveBeenCalledWith('friend_request_create', 'msg-4', 'attribution_mismatch');
});
it('non-terminal rejection (processing_error) does NOT invoke rollback and retains the outbox row', async () => {
seedPeer('peer-r5');
seedOutboxEntry('entry-r5', 'peer-r5', 'msg-5', 'friend_request_create');
vi.spyOn(globalThis, 'fetch').mockImplementation(async () =>
new Response(JSON.stringify({
accepted: [],
rejected: [{ messageId: 'msg-5', reason: 'processing_error' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
const workerModule = await import('./federationWorker.js');
const processOutboxTick = (workerModule as { processOutboxTick?: () => Promise<void> }).processOutboxTick!;
await processOutboxTick();
const remaining = testDb.select().from(schema.federationOutbox)
.where(eq(schema.federationOutbox.id, 'entry-r5')).get();
expect(remaining).toBeDefined();
expect(invokeRollbackMock).not.toHaveBeenCalled();
});
});
+49 -11
View File
@@ -12,6 +12,7 @@ import { connectionManager } from '../ws/handler.js';
import { generateThumbnail } from './thumbnail.js'; import { generateThumbnail } from './thumbnail.js';
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent } from '@backspace/shared'; import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent } from '@backspace/shared';
import { onPeerActivated, startupBootstrapSync, onPeerDeactivated } from './federationPeerActivation.js'; import { onPeerActivated, startupBootstrapSync, onPeerDeactivated } from './federationPeerActivation.js';
import { invokePermanentFailureCallback } from './federationRollback.js';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
@@ -49,6 +50,24 @@ const BACKOFF_SCHEDULE_MS: readonly number[] = [
const MAX_FILE_ATTEMPTS = 10; const MAX_FILE_ATTEMPTS = 10;
const PEER_UNREACHABLE_THRESHOLD = 10; const PEER_UNREACHABLE_THRESHOLD = 10;
/**
* Outbox rejection reasons that the receiver has acknowledged as permanently
* undeliverable. These cause the outbox entry to be deleted (no retry) and
* trigger the registered permanent-failure callback for the eventType.
*
* 'duplicate' is treated as terminal-but-no-rollback (the receiver already has
* the event; nothing to roll back locally).
*
* 5xx responses, network errors, and timeouts are NOT in this set — they are
* transient and retried via the existing backoff schedule.
*/
const TERMINAL_REJECTION_REASONS = new Set<string>([
'duplicate', // peer already has it (existing behavior)
'recipient_not_found', // receiver doesn't know the target user
'attribution_mismatch', // payload claims a homeInstance the source can't authoritatively speak for
'unknown_event_type', // peer doesn't understand this eventType — never will
]);
// ─── Worker State ─────────────────────────────────────────────────────────── // ─── Worker State ───────────────────────────────────────────────────────────
let outboxTimer: ReturnType<typeof setTimeout> | null = null; let outboxTimer: ReturnType<typeof setTimeout> | null = null;
@@ -230,15 +249,25 @@ export async function processOutboxTick(): Promise<void> {
if (response.ok) { if (response.ok) {
const result = await response.json() as FederationRelayResponse; const result = await response.json() as FederationRelayResponse;
// Terminal outcomes = accepted + duplicate-rejected. // Terminal rejection reasons: receiver acknowledged the event is permanently
// `duplicate` means the peer already has the message (e.g., delivered // undeliverable. Retrying will fail forever — remove from outbox.
// earlier via outbox or pulled via sync). Retrying will fail with // Non-`duplicate` terminals additionally invoke any registered permanent-
// `duplicate` forever until TTL expires — treat it as effectively- // failure callback for the eventType so the originator can roll back local
// accepted and remove the outbox entry. // state (e.g., friend_request_create deletes the local friend_requests row).
const terminalEntityIds = new Set<string>(result.accepted); const terminalEntityIds = new Set<string>(result.accepted);
const terminalForRollback: Array<{ messageId: string; reason: string; eventType: string | null }> = [];
for (const rejection of result.rejected) { for (const rejection of result.rejected) {
if (rejection.reason === 'duplicate') { if (TERMINAL_REJECTION_REASONS.has(rejection.reason)) {
terminalEntityIds.add(rejection.messageId); terminalEntityIds.add(rejection.messageId);
if (rejection.reason !== 'duplicate') {
const entry = peerEntries.find(e => e.entityId === rejection.messageId);
terminalForRollback.push({
messageId: rejection.messageId,
reason: rejection.reason,
eventType: entry?.eventType ?? null,
});
}
} }
} }
@@ -254,13 +283,22 @@ export async function processOutboxTick(): Promise<void> {
} }
} }
// Log rejected entries. Duplicate is terminal (outbox entry already // Invoke registered rollback callbacks AFTER deleting the outbox row,
// removed above) — log at info level. Other reasons are transient / // so the rollback runs in a clean state. The registry catches and logs
// retained for retry — log at warn level. // callback errors — they cannot prevent outbox cleanup.
for (const { messageId, reason, eventType } of terminalForRollback) {
if (eventType) {
invokePermanentFailureCallback(eventType, messageId, reason);
}
}
// Log rejected entries. Terminal reasons (incl. 'duplicate') are logged
// at info level — outbox entry already removed. Non-terminals stay in
// outbox for retry and log at warn level.
for (const rejection of result.rejected) { for (const rejection of result.rejected) {
if (rejection.reason === 'duplicate') { if (TERMINAL_REJECTION_REASONS.has(rejection.reason)) {
console.log( console.log(
`[federation-worker] Peer ${peerOrigin} rejected message ${rejection.messageId} as duplicate — outbox entry removed (terminal)`, `[federation-worker] Peer ${peerOrigin} terminal rejection ${rejection.messageId}: ${rejection.reason} — outbox entry removed`,
); );
} else { } else {
console.warn( console.warn(
+18
View File
@@ -438,6 +438,8 @@ export type ServerEvent =
| { type: 'friend_removed'; userId: string } | { type: 'friend_removed'; userId: string }
| { type: 'friend_request_cancelled'; requestId: string; userId: string } | { type: 'friend_request_cancelled'; requestId: string; userId: string }
| { type: 'friend_request_declined'; requestId: string; userId: string } | { type: 'friend_request_declined'; requestId: string; userId: string }
| { type: 'friend_request_sent'; request: FriendRequest }
| { type: 'friend_request_relay_failed'; requestId: string; reason: 'user_not_found' | 'peer_rejected'; message: string; targetHandle: string }
| { type: 'channel_created'; channel: Channel; spaceId: string } | { type: 'channel_created'; channel: Channel; spaceId: string }
| { type: 'channel_updated'; channel: Channel; spaceId: string } | { type: 'channel_updated'; channel: Channel; spaceId: string }
| { type: 'channel_deleted'; channelId: string; spaceId: string } | { type: 'channel_deleted'; channelId: string; spaceId: string }
@@ -993,6 +995,22 @@ export interface FederationSyncResponse {
checkpoint: number; checkpoint: number;
} }
export interface FederationUserLookupRequest {
username: string;
}
export interface FederationUserLookupProfile {
displayName: string | null;
avatar: string | null;
avatarColor: AvatarColor | null;
banner: string | null;
bio: string | null;
}
export type FederationUserLookupResponse =
| { found: true; user: { homeUserId: string; username: string; profile: FederationUserLookupProfile } }
| { found: false; code: 'user_not_found' };
export interface FederationPeer { export interface FederationPeer {
id: string; id: string;
origin: string; origin: string;
@@ -1,9 +1,9 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react'; import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useSocialStore, type TaggedFriend, type TaggedFriendRequest, type TaggedUser, InstanceNotConnectedError, InstanceDisconnectedError } from '../../stores/socialStore'; import { useSocialStore, type TaggedFriend, type TaggedFriendRequest, type TaggedUser } from '../../stores/socialStore';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { ConnectInstanceModal } from '../modals/ConnectInstanceModal';
import { useDiscoverStore, type TaggedDiscoverUser } from '../../stores/discoverStore'; import { useDiscoverStore, type TaggedDiscoverUser } from '../../stores/discoverStore';
import { mapServerErrorToMessage } from '../../utils/friendErrors';
import { useSpaceStore } from '../../stores/spaceStore'; import { useSpaceStore } from '../../stores/spaceStore';
import { useInstanceStore } from '../../stores/instanceStore'; import { useInstanceStore } from '../../stores/instanceStore';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
@@ -398,11 +398,6 @@ function AddFriendTab({
const [rawSearchResults, setRawSearchResults] = useState<TaggedUser[]>([]); const [rawSearchResults, setRawSearchResults] = useState<TaggedUser[]>([]);
const [searchLoading, setSearchLoading] = useState(false); const [searchLoading, setSearchLoading] = useState(false);
const [directAddLoading, setDirectAddLoading] = useState(false); const [directAddLoading, setDirectAddLoading] = useState(false);
const [connectModal, setConnectModal] = useState<{
domain: string;
isReconnect: boolean;
username: string;
} | null>(null);
// Fetch discover on mount // Fetch discover on mount
useEffect(() => { useEffect(() => {
@@ -481,34 +476,17 @@ function AddFriendTab({
addToast('Friend request sent!', 'success'); addToast('Friend request sent!', 'success');
setQuery(''); setQuery('');
} catch (err) { } catch (err) {
if (err instanceof InstanceNotConnectedError) { // The shared API client throws `new Error(body.error)` for non-2xx
setConnectModal({ domain: err.domain, isReconnect: false, username: query.trim() }); // responses (api/client.ts:298), so `err.message` carries the server's
} else if (err instanceof InstanceDisconnectedError) { // error code (e.g. 'peer_pending_approval'). It also doubles as fallback
setConnectModal({ domain: err.domain, isReconnect: true, username: query.trim() }); // text if the code is unrecognized by mapServerErrorToMessage.
} else { const code = err instanceof Error ? err.message : undefined;
addToast((err as Error).message, 'warning'); addToast(mapServerErrorToMessage(code, code, query.trim()), 'warning');
}
} finally { } finally {
setDirectAddLoading(false); setDirectAddLoading(false);
} }
}; };
// Connect modal handler
const handleConnected = async (result: 'new' | 'reconnect') => {
const username = connectModal?.username;
const domain = connectModal?.domain;
setConnectModal(null);
if (!username) return;
try {
await sendFriendRequest(username);
const verb = result === 'reconnect' ? 'Reconnected to' : 'Connected to';
addToast(`${verb} ${domain} — friend request sent!`, 'success');
setQuery('');
} catch (err) {
addToast((err as Error).message, 'warning');
}
};
// No-op relationship change for search mode cards (useMemo re-derives from store) // No-op relationship change for search mode cards (useMemo re-derives from store)
const noopRelationshipChange = useCallback(() => {}, []); const noopRelationshipChange = useCallback(() => {}, []);
@@ -618,15 +596,6 @@ function AddFriendTab({
)} )}
</div> </div>
{connectModal && (
<ConnectInstanceModal
domain={connectModal.domain}
targetDisplayName={connectModal.username}
isReconnect={connectModal.isReconnect}
onConnected={handleConnected}
onCancel={() => setConnectModal(null)}
/>
)}
</div> </div>
); );
} }
@@ -647,12 +616,6 @@ function UserDiscoverCard({
const openModal = useUIStore((s) => s.openModal); const openModal = useUIStore((s) => s.openModal);
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const addToast = useUIStore((s) => s.addToast);
const [connectModal, setConnectModal] = useState<{
domain: string;
isReconnect: boolean;
username: string;
} | null>(null);
const baseName = user.username.includes('@') ? user.username.split('@')[0]! : user.username; const baseName = user.username.includes('@') ? user.username.split('@')[0]! : user.username;
const displayName = user.displayName ?? baseName; const displayName = user.displayName ?? baseName;
@@ -676,32 +639,9 @@ function UserDiscoverCard({
const requestId = await sendFriendRequest(username); const requestId = await sendFriendRequest(username);
onRelationshipChange(user.id, user._instanceOrigin, 'outbound_pending', requestId); onRelationshipChange(user.id, user._instanceOrigin, 'outbound_pending', requestId);
} catch (err) { } catch (err) {
if (err instanceof InstanceNotConnectedError) { // See handleDirectAdd above — err.message is the server error code.
setConnectModal({ domain: err.domain, isReconnect: false, username }); const code = err instanceof Error ? err.message : undefined;
} else if (err instanceof InstanceDisconnectedError) { setError(mapServerErrorToMessage(code, code ?? 'Failed to send request', username));
setConnectModal({ domain: err.domain, isReconnect: true, username });
} else {
setError(err instanceof Error ? err.message : 'Failed to send request');
}
} finally {
setActionLoading(false);
}
};
const handleDiscoverConnected = async (result: 'new' | 'reconnect') => {
const username = connectModal?.username;
const domain = connectModal?.domain;
setConnectModal(null);
if (!username) return;
setActionLoading(true);
try {
const requestId = await sendFriendRequest(username);
onRelationshipChange(user.id, user._instanceOrigin, 'outbound_pending', requestId);
const verb = result === 'reconnect' ? 'Reconnected to' : 'Connected to';
addToast(`${verb} ${domain} — friend request sent!`, 'success');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to send request');
} finally { } finally {
setActionLoading(false); setActionLoading(false);
} }
@@ -885,15 +825,6 @@ function UserDiscoverCard({
</button> </button>
)} )}
</div> </div>
{connectModal && (
<ConnectInstanceModal
domain={connectModal.domain}
targetDisplayName={user.displayName ?? baseName}
isReconnect={connectModal.isReconnect}
onConnected={handleDiscoverConnected}
onCancel={() => setConnectModal(null)}
/>
)}
</div> </div>
); );
} }
@@ -7,8 +7,8 @@ import { Username } from '../ui/Username';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore'; import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore';
import { api } from '../../api/client'; import { api } from '../../api/client';
import { useSocialStore, type TaggedFriend, type TaggedFriendRequest, InstanceNotConnectedError, InstanceDisconnectedError } from '../../stores/socialStore'; import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore';
import { ConnectInstanceModal } from './ConnectInstanceModal'; import { mapServerErrorToMessage } from '../../utils/friendErrors';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { getAvatarGradient, getSpaceGradient, adjustColor, mutedGradient } from '../../utils/gradients'; import { getAvatarGradient, getSpaceGradient, adjustColor, mutedGradient } from '../../utils/gradients';
import { parseFederatedUsername, isSelf, canonicalUserMatch } from '../../utils/identity'; import { parseFederatedUsername, isSelf, canonicalUserMatch } from '../../utils/identity';
@@ -71,10 +71,6 @@ export function UserProfileModal() {
const [mutualSpaces, setMutualSpaces] = useState<MutualSpace[]>([]); const [mutualSpaces, setMutualSpaces] = useState<MutualSpace[]>([]);
const [loadingMutuals, setLoadingMutuals] = useState(false); const [loadingMutuals, setLoadingMutuals] = useState(false);
const [friendActionLoading, setFriendActionLoading] = useState(false); const [friendActionLoading, setFriendActionLoading] = useState(false);
const [connectModal, setConnectModal] = useState<{
domain: string;
isReconnect: boolean;
} | null>(null);
const isOpen = activeModal === 'userProfile'; const isOpen = activeModal === 'userProfile';
const userId = modalData?.userId as string | undefined; const userId = modalData?.userId as string | undefined;
@@ -190,29 +186,11 @@ export function UserProfileModal() {
try { try {
await sendFriendRequest(user.username); await sendFriendRequest(user.username);
} catch (err) { } catch (err) {
if (err instanceof InstanceNotConnectedError) { // The shared API client throws `new Error(body.error)` for non-2xx
setConnectModal({ domain: err.domain, isReconnect: false }); // responses (api/client.ts:298), so err.message carries the server's
} else if (err instanceof InstanceDisconnectedError) { // error code (e.g. 'peer_pending_approval').
setConnectModal({ domain: err.domain, isReconnect: true }); const code = err instanceof Error ? err.message : undefined;
} addToast(mapServerErrorToMessage(code, code, user.username), 'warning');
// Other errors: socialStore already sets its own error state
} finally {
setFriendActionLoading(false);
}
};
const handleConnected = async (result: 'new' | 'reconnect') => {
const domain = connectModal?.domain; // capture before clearing
setConnectModal(null);
// Retry the friend request now that we're connected
setFriendActionLoading(true);
try {
await sendFriendRequest(user.username);
const verb = result === 'reconnect' ? 'Reconnected to' : 'Connected to';
addToast(`${verb} ${domain} — friend request sent to ${user.displayName ?? parseFederatedUsername(user.username).baseName}`, 'success');
} catch (err) {
// Connection succeeded but friend request failed — still valuable
addToast((err as Error).message, 'warning');
} finally { } finally {
setFriendActionLoading(false); setFriendActionLoading(false);
} }
@@ -559,15 +537,6 @@ export function UserProfileModal() {
</div> </div>
</div> </div>
{connectModal && user && (
<ConnectInstanceModal
domain={connectModal.domain}
targetDisplayName={user.displayName ?? parseFederatedUsername(user.username).baseName}
isReconnect={connectModal.isReconnect}
onConnected={handleConnected}
onCancel={() => setConnectModal(null)}
/>
)}
</div> </div>
); );
} }
+21
View File
@@ -862,6 +862,27 @@ function handleEvent(origin: string, event: ServerEvent): void {
break; break;
} }
case 'friend_request_sent': {
// Multi-tab sync: another tab/device of the same user just created an outbound request.
if (!isHome && event.request.user) normalizeUserAssets(event.request.user, origin);
const { addOutboundRequest } = useSocialStore.getState();
addOutboundRequest(event.request, origin);
break;
}
case 'friend_request_relay_failed': {
// Async rollback notification: the federated friend_request_create was permanently rejected.
// Drop the optimistic row and surface a warning toast.
const { removeRequestById } = useSocialStore.getState();
removeRequestById(event.requestId, origin);
const { addToast } = useUIStore.getState();
addToast(
`Friend request to ${event.targetHandle} could not be delivered: ${event.message}`,
'warning',
);
break;
}
case 'friend_request_accepted': { case 'friend_request_accepted': {
if (!isHome) normalizeUserAssets(event.friend, origin); if (!isHome) normalizeUserAssets(event.friend, origin);
const { addFriendFromAccepted } = useSocialStore.getState(); const { addFriendFromAccepted } = useSocialStore.getState();
@@ -1,9 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
const homeSendRequest = vi.fn(async () => ({ success: true, requestId: 'req-home' })); const homeSendRequest = vi.fn(async () => ({ success: true, requestId: 'req-1' }));
const remoteSendRequest = vi.fn(async () => ({ success: true, requestId: 'req-remote' }));
const homeRequests = vi.fn(async () => []); const homeRequests = vi.fn(async () => []);
const remoteRequests = vi.fn(async () => []);
vi.mock('../api/client', () => ({ vi.mock('../api/client', () => ({
api: { api: {
@@ -14,75 +12,52 @@ vi.mock('../api/client', () => ({
}, },
})); }));
const remoteApi = {
social: {
sendRequest: (...args: unknown[]) => remoteSendRequest(...args),
requests: () => remoteRequests(),
},
};
vi.mock('./instanceStore', () => ({
useInstanceStore: {
getState: () => ({
instances: [
{
origin: 'https://orbit.ddns.net',
status: 'connected',
api: remoteApi,
},
],
}),
},
}));
vi.mock('../utils/assetUrls', () => ({ vi.mock('../utils/assetUrls', () => ({
normalizeUserAssets: (u: unknown) => u, normalizeUserAssets: (u: unknown) => u,
})); }));
// instanceStore is still imported by other socialStore methods (loadFriends, loadRequests)
// — provide an empty-instances stub so those calls don't crash.
vi.mock('./instanceStore', () => ({
useInstanceStore: {
getState: () => ({ instances: [], _autoConnectDone: true }),
subscribe: () => () => {},
},
}));
import { useSocialStore } from './socialStore'; import { useSocialStore } from './socialStore';
describe('socialStore.sendFriendRequest — case-insensitive domain routing', () => { describe('socialStore.sendFriendRequest — server-side routing (post-S2S)', () => {
beforeEach(() => { beforeEach(() => {
homeSendRequest.mockClear(); homeSendRequest.mockClear();
remoteSendRequest.mockClear();
homeRequests.mockClear(); homeRequests.mockClear();
remoteRequests.mockClear();
// window.location.host in jsdom defaults to 'localhost:3000' or similar.
// Override it for routing tests.
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: { ...window.location, host: 'local.test', hostname: 'local.test' },
});
}); });
it('sends to the home API when the typed domain matches window.location.host exactly', async () => { it('sends bare handle to home API as-is', async () => {
const id = await useSocialStore.getState().sendFriendRequest('bob');
expect(homeSendRequest).toHaveBeenCalledOnce();
expect(homeSendRequest).toHaveBeenCalledWith('bob');
expect(id).toBe('req-1');
});
it('sends @-handle to home API verbatim (server handles routing)', async () => {
await useSocialStore.getState().sendFriendRequest('bob@orbit.tld');
expect(homeSendRequest).toHaveBeenCalledWith('bob@orbit.tld');
});
it('sends @-handle for own host to home API verbatim', async () => {
await useSocialStore.getState().sendFriendRequest('bob@local.test'); await useSocialStore.getState().sendFriendRequest('bob@local.test');
expect(homeSendRequest).toHaveBeenCalledWith('bob@local.test');
});
it('trims whitespace before sending', async () => {
await useSocialStore.getState().sendFriendRequest(' bob ');
expect(homeSendRequest).toHaveBeenCalledWith('bob'); expect(homeSendRequest).toHaveBeenCalledWith('bob');
expect(remoteSendRequest).not.toHaveBeenCalled();
}); });
it('sends to the home API when the typed domain matches with mixed case', async () => { it('propagates server errors and sets store.error', async () => {
await useSocialStore.getState().sendFriendRequest('bob@LOCAL.TEST'); homeSendRequest.mockRejectedValueOnce(new Error('user_not_found'));
expect(homeSendRequest).toHaveBeenCalledWith('bob'); await expect(useSocialStore.getState().sendFriendRequest('nope')).rejects.toThrow('user_not_found');
expect(remoteSendRequest).not.toHaveBeenCalled(); expect(useSocialStore.getState().error).toBe('user_not_found');
});
it('routes to a connected remote instance when the typed domain matches its origin host', async () => {
await useSocialStore.getState().sendFriendRequest('bob@orbit.ddns.net');
expect(remoteSendRequest).toHaveBeenCalledWith('bob');
expect(homeSendRequest).not.toHaveBeenCalled();
});
it('routes to a connected remote instance when the typed domain has mixed case', async () => {
await useSocialStore.getState().sendFriendRequest('bob@ORBIT.ddns.net');
expect(remoteSendRequest).toHaveBeenCalledWith('bob');
expect(homeSendRequest).not.toHaveBeenCalled();
});
it('sends bare handle (no @) directly to the home API', async () => {
await useSocialStore.getState().sendFriendRequest('bob');
expect(homeSendRequest).toHaveBeenCalledWith('bob');
expect(remoteSendRequest).not.toHaveBeenCalled();
}); });
}); });
+17 -56
View File
@@ -4,24 +4,6 @@ import { api } from '../api/client';
import { useInstanceStore } from './instanceStore'; import { useInstanceStore } from './instanceStore';
import { normalizeUserAssets } from '../utils/assetUrls'; import { normalizeUserAssets } from '../utils/assetUrls';
// ─── Federation errors ────────────────────────────────────────────────────
/** Thrown when the target domain has never been connected. */
export class InstanceNotConnectedError extends Error {
constructor(public domain: string) {
super(`Not connected to ${domain}`);
this.name = 'InstanceNotConnectedError';
}
}
/** Thrown when the instance entry exists but the session is disconnected/errored. */
export class InstanceDisconnectedError extends Error {
constructor(public domain: string) {
super(`Instance ${domain} is not currently connected`);
this.name = 'InstanceDisconnectedError';
}
}
// ─── Tagged types (origin tracking for federation) ─────────────────────────── // ─── Tagged types (origin tracking for federation) ───────────────────────────
export type TaggedFriend = Friend & { _instanceOrigin: string }; export type TaggedFriend = Friend & { _instanceOrigin: string };
@@ -75,6 +57,7 @@ interface SocialState {
removeFriend: (id: string) => Promise<void>; removeFriend: (id: string) => Promise<void>;
searchUsers: (query: string) => Promise<TaggedUser[]>; searchUsers: (query: string) => Promise<TaggedUser[]>;
addIncomingRequest: (request: FriendRequest, origin: string) => void; addIncomingRequest: (request: FriendRequest, origin: string) => void;
addOutboundRequest: (request: FriendRequest, origin: string) => void;
addFriendFromAccepted: (friend: Friend, requestId: string, origin: string) => void; addFriendFromAccepted: (friend: Friend, requestId: string, origin: string) => void;
updateFriendPresence: (userId: string, status: string) => void; updateFriendPresence: (userId: string, status: string) => void;
updateFriendProfile: (user: User) => void; updateFriendProfile: (user: User) => void;
@@ -210,44 +193,11 @@ export const useSocialStore = create<SocialState>((set, get) => ({
sendFriendRequest: async (username: string) => { sendFriendRequest: async (username: string) => {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const atIndex = username.lastIndexOf('@'); const res = await api.social.sendRequest(username.trim());
let res: { success: boolean; requestId?: string }; set({ isLoading: false });
// Server emits friend_request_sent over WS; useWebSocket appends the row
if (atIndex === -1) { // optimistically. As a safety net for tabs that race the WS event, refresh
// No @ → local user on home instance // from server too.
res = await api.social.sendRequest(username);
} else {
const baseName = username.slice(0, atIndex);
const domain = username.slice(atIndex + 1).toLowerCase();
// Check if domain matches home instance
if (domain === window.location.host) {
// Strip domain, send to home API
res = await api.social.sendRequest(baseName);
} else {
// Find a connected instance matching this domain
const instances = useInstanceStore.getState().instances;
const match = instances.find(inst => {
try {
return new URL(inst.origin).host === domain;
} catch {
return false;
}
});
if (!match) {
throw new InstanceNotConnectedError(domain);
}
if (match.status !== 'connected') {
throw new InstanceDisconnectedError(domain);
}
// On the remote instance, the user is just "alice", not "alice@orbit"
res = await match.api.social.sendRequest(baseName);
}
}
await get().loadRequests(); await get().loadRequests();
return res.requestId; return res.requestId;
} catch (err) { } catch (err) {
@@ -394,6 +344,17 @@ export const useSocialStore = create<SocialState>((set, get) => ({
}); });
}, },
// Called from WS handler for multi-tab sync when this user creates an outbound request
addOutboundRequest: (request: FriendRequest, origin: string) => {
set((state) => {
const canonicalId = request.user?.homeUserId ?? request.user?.id;
if (canonicalId && state.requests.some(r => (r.user?.homeUserId ?? r.user?.id) === canonicalId)) {
return state;
}
return { requests: [...state.requests, { ...request, _instanceOrigin: origin }] };
});
},
// Called from WS handler when someone accepts your friend request // Called from WS handler when someone accepts your friend request
addFriendFromAccepted: (friend: Friend, requestId: string, origin: string) => { addFriendFromAccepted: (friend: Friend, requestId: string, origin: string) => {
set((state) => { set((state) => {
+35
View File
@@ -0,0 +1,35 @@
/**
* Map a server error code (from POST /api/social/requests) to a human-readable
* toast message. The server emits these codes; the client renders them.
*
* Used by FriendsPage and UserProfileModal when the server returns an error
* from the friend-add flow.
*/
export function mapServerErrorToMessage(
code: string | undefined,
fallback: string | undefined,
handle: string,
): string {
switch (code) {
case 'username_required': return 'Enter a username.';
case 'cannot_friend_self': return "You can't friend yourself.";
case 'peer_rejected':
return `Instance has rejected federation. Contact your admin.`;
case 'user_not_found':
return `No user "${handle}" on the remote instance.`;
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':
return 'Connecting to the remote instance — try again in a moment.';
case 'incoming_request_exists':
return `${handle} has already sent you a request — open the Pending tab.`;
case 'lookup_rate_limited': return 'Too many lookups; try again in a minute.';
case 'peer_unreachable': return 'The remote instance is currently unreachable.';
case 'invalid_target_domain': return 'Invalid target domain.';
case 'not_authoritative_for_sender':
// Should not happen in normal client usage — internal protocol violation.
return 'Could not send friend request (authority error).';
default: return fallback ?? 'Could not send friend request.';
}
}