Merge branch 'feat/s2s-friend-add'
This commit is contained in:
+25
-1
@@ -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? }
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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', {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user