From 0574a10ff3fa912a9aa0b186b6b2189c71ad268f Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:04:36 +0200 Subject: [PATCH] feat(social): federated branch for POST /api/social/requests (happy path) Refactors the POST handler into handleLocalFriendRequest + handleFederatedFriendRequest helpers. The federated branch resolves the target domain, ensures peering, looks up the remote user, creates/hydrates a replicated stub, and writes a transactional (friend_requests + mutation_log + outbox) event with relayMessageId set. Also exports hydrateReplicatedUserProfile from federation.ts and adds the T11 happy-path test. --- packages/server/src/routes/federation.ts | 2 +- .../src/routes/social.federated.test.ts | 173 ++++++++ packages/server/src/routes/social.test.ts | 7 +- packages/server/src/routes/social.ts | 396 +++++++++++++----- 4 files changed, 465 insertions(+), 113 deletions(-) create mode 100644 packages/server/src/routes/social.federated.test.ts diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 18ab811a..6a30e081 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -3845,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, diff --git a/packages/server/src/routes/social.federated.test.ts b/packages/server/src/routes/social.federated.test.ts new file mode 100644 index 00000000..6a259df6 --- /dev/null +++ b/packages/server/src/routes/social.federated.test.ts @@ -0,0 +1,173 @@ +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>; +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(); + 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 { + 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 + expect(sendToUser).toHaveBeenCalledWith( + CALLER_ID, + expect.objectContaining({ + type: 'friend_request_sent', + request: expect.objectContaining({ id: body.requestId }), + }), + ); + }); +}); diff --git a/packages/server/src/routes/social.test.ts b/packages/server/src/routes/social.test.ts index 7efe6790..df0f0850 100644 --- a/packages/server/src/routes/social.test.ts +++ b/packages/server/src/routes/social.test.ts @@ -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(); + return { ...actual, getOurOrigin: () => 'https://local.test' }; +}); function applyMigrations(db: Database.Database): void { const migrationsDir = path.resolve(__dirname, '../../drizzle'); diff --git a/packages/server/src/routes/social.ts b/packages/server/src/routes/social.ts index bb8f769f..946f5a1f 100644 --- a/packages/server/src/routes/social.ts +++ b/packages/server/src/routes/social.ts @@ -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,260 @@ function buildProfileSnapshot(user: typeof schema.users.$inferSelect): Federatio }; } +// ─── Local friend request helper ───────────────────────────────────────────── + +async function handleLocalFriendRequest( + db: ReturnType, + request: FastifyRequest, + reply: FastifyReply, + localUsername: string, + sender: typeof schema.users.$inferSelect, + ourOrigin: string, +): Promise { + // 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 friend_request_received to the target user + const friendRequestPayload: FriendRequest = { + id, + fromId: request.userId, + toId: targetUser.id, + status: 'pending', + createdAt: now, + user: sanitizeUser(sender), + }; + + connectionManager.sendToUser(targetUser.id, { + type: 'friend_request_received', + request: friendRequestPayload, + }); + + // 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, + request: FastifyRequest, + reply: FastifyReply, + raw: string, + atIndex: number, + sender: typeof schema.users.$inferSelect, + ourOrigin: string, +): Promise { + 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 = {}; + 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); + + // 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 { // GET /api/social/friends - List all friends app.get('/api/social/friends', { @@ -112,7 +370,7 @@ export async function socialRoutes(app: FastifyInstance): Promise { 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 +378,36 @@ export async function socialRoutes(app: FastifyInstance): Promise { 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