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/requests → { requests[] }
POST /social/requests { username } → { request }
POST /social/requests { username } → { success, requestId }
PATCH /social/requests/:id { status: 'accepted'|'declined' } → { request }
DELETE /social/requests/:id → { success } (cancel, sender-only)
DELETE /social/friends/:id → { success }
@@ -126,6 +126,27 @@ GET /social/discover ?q=&limit=&offset= → { users[], total }
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
```
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
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/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
```
GET /utils/metadata ?url= → { title?, description?, image?, siteName? }
+13 -1
View File
@@ -15,6 +15,10 @@ Source files:
## 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**.
```
@@ -50,7 +54,9 @@ Backspace supports **client-side federation**: a single app session (web or desk
## 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
@@ -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.
### 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`)
+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/denied` | POST | HMAC | Receive denial notification for awaiting_approval peer |
| `/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`)
@@ -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.
- **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)
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`
- 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.
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
| 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
**Outbound (origin 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()`
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
5. Call `appendMutationLog()` -- records in `federation_mutation_log` for sync protocol
6. Call `queueOutboxEvent()` -- inserts into `federation_outbox` for each target peer
**Outbound (sender's home instance -- `social.ts:POST /api/social/requests`):**
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:
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).
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`.
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`):**
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
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)
**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.
### 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
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()`
After success, reloads requests via `loadRequests()`.
### 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)
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
@@ -612,7 +646,7 @@ Renders a card with banner, avatar, display name, username, bio, mutual counts,
- `inbound_pending`: "Accept" / "Decline" buttons
- `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
- Banner/avatar URLs resolved through the correct API client for remote users
- Mutuals loaded via `loadFederatedMutuals()` with cross-instance fan-out
- Friend actions use `sendFriendRequest(user.username)` which handles `user@domain` routing
- `ConnectInstanceModal` shown when trying to add a friend on an unconnected instance
- Friend actions use `sendFriendRequest(user.username)` — all routing is server-side; errors surface as toasts via `mapServerErrorToMessage`
---
@@ -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,
"tag": "0000_lethal_wildside",
"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' }),
status: text('status').default('pending'), // 'pending', 'accepted', 'declined'
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) => ({
toIdx: index('idx_friend_requests_to_id').on(table.toId),
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', {
+1
View File
@@ -26,6 +26,7 @@ import { adminRoutes } from './routes/admin.js';
import { gifRoutes } from './routes/gif.js';
import { federationRoutes } from './routes/federation.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 { 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;
}
// ─── 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) ─────────────
const ensureRateBuckets = new Map<string, number[]>();
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 ──────────────────────────────────────────────
// Server-to-server: checkpoint catch-up sync. A peer calls this after downtime
// 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,
* so manually-set local values are preserved.
*/
function hydrateReplicatedUserProfile(
export function hydrateReplicatedUserProfile(
user: typeof schema.users.$inferSelect,
profile: FederationRelayProfileSnapshot | undefined,
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: () => [],
}));
vi.mock('../utils/federationAuth.js', () => ({
getOurOrigin: () => 'https://local.test',
}));
vi.mock('../utils/federationAuth.js', async (importActual) => {
const actual = await importActual<typeof import('../utils/federationAuth.js')>();
return { ...actual, getOurOrigin: () => 'https://local.test' };
});
function applyMigrations(db: Database.Database): void {
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(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 { getDb, schema } from '../db/index.js';
import { getDb, getRawDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { connectionManager } from '../ws/handler.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 {
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> {
// GET /api/social/friends - List all friends
app.get('/api/social/friends', {
@@ -112,7 +418,7 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
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', {
preHandler: authenticate,
}, async (request, reply) => {
@@ -120,116 +426,36 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
const db = getDb();
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 lookupUsername = username.trim().toLowerCase();
if (!lookupUsername) {
return reply.code(400).send({ error: 'Username is required', statusCode: 400 });
const raw = username.trim();
if (!raw) return reply.code(400).send({ error: 'username_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 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 });
const atIndex = raw.lastIndexOf('@');
const isFederated =
atIndex > 0 &&
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);
}
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 });
return handleFederatedFriendRequest(db, request, reply, raw, atIndex, sender, ourOrigin);
});
// PATCH /api/social/requests/:id - Accept/Decline a friend request
@@ -1,5 +1,5 @@
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', () => {
const primarySecret = 'a'.repeat(64);
@@ -78,3 +78,34 @@ describe('verifyPeerSignature', () => {
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}`;
}
/**
* 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');
});
});
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,
}));
const invokeRollbackMock = vi.fn();
vi.mock('./federationRollback.js', () => ({
invokePermanentFailureCallback: invokeRollbackMock,
registerPermanentFailureCallback: vi.fn(),
_resetCallbacks: vi.fn(),
}));
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
@@ -84,10 +91,10 @@ function seedPeer(id: string): void {
}).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({
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() },
}),
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 type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent } from '@backspace/shared';
import { onPeerActivated, startupBootstrapSync, onPeerDeactivated } from './federationPeerActivation.js';
import { invokePermanentFailureCallback } from './federationRollback.js';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
@@ -49,6 +50,24 @@ const BACKOFF_SCHEDULE_MS: readonly number[] = [
const MAX_FILE_ATTEMPTS = 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 ───────────────────────────────────────────────────────────
let outboxTimer: ReturnType<typeof setTimeout> | null = null;
@@ -230,15 +249,25 @@ export async function processOutboxTick(): Promise<void> {
if (response.ok) {
const result = await response.json() as FederationRelayResponse;
// Terminal outcomes = accepted + duplicate-rejected.
// `duplicate` means the peer already has the message (e.g., delivered
// earlier via outbox or pulled via sync). Retrying will fail with
// `duplicate` forever until TTL expires — treat it as effectively-
// accepted and remove the outbox entry.
// Terminal rejection reasons: receiver acknowledged the event is permanently
// undeliverable. Retrying will fail forever — remove from outbox.
// Non-`duplicate` terminals additionally invoke any registered permanent-
// failure callback for the eventType so the originator can roll back local
// state (e.g., friend_request_create deletes the local friend_requests row).
const terminalEntityIds = new Set<string>(result.accepted);
const terminalForRollback: Array<{ messageId: string; reason: string; eventType: string | null }> = [];
for (const rejection of result.rejected) {
if (rejection.reason === 'duplicate') {
if (TERMINAL_REJECTION_REASONS.has(rejection.reason)) {
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
// removed above) — log at info level. Other reasons are transient /
// retained for retry — log at warn level.
// Invoke registered rollback callbacks AFTER deleting the outbox row,
// so the rollback runs in a clean state. The registry catches and logs
// 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) {
if (rejection.reason === 'duplicate') {
if (TERMINAL_REJECTION_REASONS.has(rejection.reason)) {
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 {
console.warn(
+18
View File
@@ -438,6 +438,8 @@ export type ServerEvent =
| { type: 'friend_removed'; userId: string }
| { type: 'friend_request_cancelled'; 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_updated'; channel: Channel; spaceId: string }
| { type: 'channel_deleted'; channelId: string; spaceId: string }
@@ -993,6 +995,22 @@ export interface FederationSyncResponse {
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 {
id: string;
origin: string;
@@ -1,9 +1,9 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
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 { ConnectInstanceModal } from '../modals/ConnectInstanceModal';
import { useDiscoverStore, type TaggedDiscoverUser } from '../../stores/discoverStore';
import { mapServerErrorToMessage } from '../../utils/friendErrors';
import { useSpaceStore } from '../../stores/spaceStore';
import { useInstanceStore } from '../../stores/instanceStore';
import { useUIStore } from '../../stores/uiStore';
@@ -398,11 +398,6 @@ function AddFriendTab({
const [rawSearchResults, setRawSearchResults] = useState<TaggedUser[]>([]);
const [searchLoading, setSearchLoading] = useState(false);
const [directAddLoading, setDirectAddLoading] = useState(false);
const [connectModal, setConnectModal] = useState<{
domain: string;
isReconnect: boolean;
username: string;
} | null>(null);
// Fetch discover on mount
useEffect(() => {
@@ -481,34 +476,17 @@ function AddFriendTab({
addToast('Friend request sent!', 'success');
setQuery('');
} catch (err) {
if (err instanceof InstanceNotConnectedError) {
setConnectModal({ domain: err.domain, isReconnect: false, username: query.trim() });
} else if (err instanceof InstanceDisconnectedError) {
setConnectModal({ domain: err.domain, isReconnect: true, username: query.trim() });
} else {
addToast((err as Error).message, 'warning');
}
// The shared API client throws `new Error(body.error)` for non-2xx
// responses (api/client.ts:298), so `err.message` carries the server's
// error code (e.g. 'peer_pending_approval'). It also doubles as fallback
// text if the code is unrecognized by mapServerErrorToMessage.
const code = err instanceof Error ? err.message : undefined;
addToast(mapServerErrorToMessage(code, code, query.trim()), 'warning');
} finally {
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)
const noopRelationshipChange = useCallback(() => {}, []);
@@ -618,15 +596,6 @@ function AddFriendTab({
)}
</div>
{connectModal && (
<ConnectInstanceModal
domain={connectModal.domain}
targetDisplayName={connectModal.username}
isReconnect={connectModal.isReconnect}
onConnected={handleConnected}
onCancel={() => setConnectModal(null)}
/>
)}
</div>
);
}
@@ -647,12 +616,6 @@ function UserDiscoverCard({
const openModal = useUIStore((s) => s.openModal);
const [actionLoading, setActionLoading] = useState(false);
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 displayName = user.displayName ?? baseName;
@@ -676,32 +639,9 @@ function UserDiscoverCard({
const requestId = await sendFriendRequest(username);
onRelationshipChange(user.id, user._instanceOrigin, 'outbound_pending', requestId);
} catch (err) {
if (err instanceof InstanceNotConnectedError) {
setConnectModal({ domain: err.domain, isReconnect: false, username });
} else if (err instanceof InstanceDisconnectedError) {
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');
// See handleDirectAdd above — err.message is the server error code.
const code = err instanceof Error ? err.message : undefined;
setError(mapServerErrorToMessage(code, code ?? 'Failed to send request', username));
} finally {
setActionLoading(false);
}
@@ -885,15 +825,6 @@ function UserDiscoverCard({
</button>
)}
</div>
{connectModal && (
<ConnectInstanceModal
domain={connectModal.domain}
targetDisplayName={user.displayName ?? baseName}
isReconnect={connectModal.isReconnect}
onConnected={handleDiscoverConnected}
onCancel={() => setConnectModal(null)}
/>
)}
</div>
);
}
@@ -7,8 +7,8 @@ import { Username } from '../ui/Username';
import { useUIStore } from '../../stores/uiStore';
import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore';
import { api } from '../../api/client';
import { useSocialStore, type TaggedFriend, type TaggedFriendRequest, InstanceNotConnectedError, InstanceDisconnectedError } from '../../stores/socialStore';
import { ConnectInstanceModal } from './ConnectInstanceModal';
import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore';
import { mapServerErrorToMessage } from '../../utils/friendErrors';
import { useAuthStore } from '../../stores/authStore';
import { getAvatarGradient, getSpaceGradient, adjustColor, mutedGradient } from '../../utils/gradients';
import { parseFederatedUsername, isSelf, canonicalUserMatch } from '../../utils/identity';
@@ -71,10 +71,6 @@ export function UserProfileModal() {
const [mutualSpaces, setMutualSpaces] = useState<MutualSpace[]>([]);
const [loadingMutuals, setLoadingMutuals] = useState(false);
const [friendActionLoading, setFriendActionLoading] = useState(false);
const [connectModal, setConnectModal] = useState<{
domain: string;
isReconnect: boolean;
} | null>(null);
const isOpen = activeModal === 'userProfile';
const userId = modalData?.userId as string | undefined;
@@ -190,29 +186,11 @@ export function UserProfileModal() {
try {
await sendFriendRequest(user.username);
} catch (err) {
if (err instanceof InstanceNotConnectedError) {
setConnectModal({ domain: err.domain, isReconnect: false });
} else if (err instanceof InstanceDisconnectedError) {
setConnectModal({ domain: err.domain, isReconnect: true });
}
// 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');
// The shared API client throws `new Error(body.error)` for non-2xx
// responses (api/client.ts:298), so err.message carries the server's
// error code (e.g. 'peer_pending_approval').
const code = err instanceof Error ? err.message : undefined;
addToast(mapServerErrorToMessage(code, code, user.username), 'warning');
} finally {
setFriendActionLoading(false);
}
@@ -559,15 +537,6 @@ export function UserProfileModal() {
</div>
</div>
{connectModal && user && (
<ConnectInstanceModal
domain={connectModal.domain}
targetDisplayName={user.displayName ?? parseFederatedUsername(user.username).baseName}
isReconnect={connectModal.isReconnect}
onConnected={handleConnected}
onCancel={() => setConnectModal(null)}
/>
)}
</div>
);
}
+21
View File
@@ -862,6 +862,27 @@ function handleEvent(origin: string, event: ServerEvent): void {
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': {
if (!isHome) normalizeUserAssets(event.friend, origin);
const { addFriendFromAccepted } = useSocialStore.getState();
@@ -1,9 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
const homeSendRequest = vi.fn(async () => ({ success: true, requestId: 'req-home' }));
const remoteSendRequest = vi.fn(async () => ({ success: true, requestId: 'req-remote' }));
const homeSendRequest = vi.fn(async () => ({ success: true, requestId: 'req-1' }));
const homeRequests = vi.fn(async () => []);
const remoteRequests = vi.fn(async () => []);
vi.mock('../api/client', () => ({
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', () => ({
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';
describe('socialStore.sendFriendRequest — case-insensitive domain routing', () => {
describe('socialStore.sendFriendRequest — server-side routing (post-S2S)', () => {
beforeEach(() => {
homeSendRequest.mockClear();
remoteSendRequest.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');
expect(homeSendRequest).toHaveBeenCalledWith('bob@local.test');
});
it('trims whitespace before sending', async () => {
await useSocialStore.getState().sendFriendRequest(' bob ');
expect(homeSendRequest).toHaveBeenCalledWith('bob');
expect(remoteSendRequest).not.toHaveBeenCalled();
});
it('sends to the home API when the typed domain matches with mixed case', async () => {
await useSocialStore.getState().sendFriendRequest('bob@LOCAL.TEST');
expect(homeSendRequest).toHaveBeenCalledWith('bob');
expect(remoteSendRequest).not.toHaveBeenCalled();
});
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();
it('propagates server errors and sets store.error', async () => {
homeSendRequest.mockRejectedValueOnce(new Error('user_not_found'));
await expect(useSocialStore.getState().sendFriendRequest('nope')).rejects.toThrow('user_not_found');
expect(useSocialStore.getState().error).toBe('user_not_found');
});
});
+17 -56
View File
@@ -4,24 +4,6 @@ import { api } from '../api/client';
import { useInstanceStore } from './instanceStore';
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) ───────────────────────────
export type TaggedFriend = Friend & { _instanceOrigin: string };
@@ -75,6 +57,7 @@ interface SocialState {
removeFriend: (id: string) => Promise<void>;
searchUsers: (query: string) => Promise<TaggedUser[]>;
addIncomingRequest: (request: FriendRequest, origin: string) => void;
addOutboundRequest: (request: FriendRequest, origin: string) => void;
addFriendFromAccepted: (friend: Friend, requestId: string, origin: string) => void;
updateFriendPresence: (userId: string, status: string) => void;
updateFriendProfile: (user: User) => void;
@@ -210,44 +193,11 @@ export const useSocialStore = create<SocialState>((set, get) => ({
sendFriendRequest: async (username: string) => {
set({ isLoading: true, error: null });
try {
const atIndex = username.lastIndexOf('@');
let res: { success: boolean; requestId?: string };
if (atIndex === -1) {
// No @ → local user on home instance
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);
}
}
const res = await api.social.sendRequest(username.trim());
set({ isLoading: false });
// Server emits friend_request_sent over WS; useWebSocket appends the row
// optimistically. As a safety net for tabs that race the WS event, refresh
// from server too.
await get().loadRequests();
return res.requestId;
} 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
addFriendFromAccepted: (friend: Friend, requestId: string, origin: string) => {
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.';
}
}