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.
This commit is contained in:
Jannis Braun
2026-04-25 22:04:36 +02:00
parent 08872f1e08
commit 0574a10ff3
4 changed files with 465 additions and 113 deletions
+1 -1
View File
@@ -3845,7 +3845,7 @@ function processOwnershipTransferEvent(
* Only updates fields that are currently null/empty on the local row, * Only updates fields that are currently null/empty on the local row,
* so manually-set local values are preserved. * so manually-set local values are preserved.
*/ */
function hydrateReplicatedUserProfile( export function hydrateReplicatedUserProfile(
user: typeof schema.users.$inferSelect, user: typeof schema.users.$inferSelect,
profile: FederationRelayProfileSnapshot | undefined, profile: FederationRelayProfileSnapshot | undefined,
db: ReturnType<typeof getDb>, db: ReturnType<typeof getDb>,
@@ -0,0 +1,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<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
expect(sendToUser).toHaveBeenCalledWith(
CALLER_ID,
expect.objectContaining({
type: 'friend_request_sent',
request: expect.objectContaining({ id: body.requestId }),
}),
);
});
});
+4 -3
View File
@@ -53,9 +53,10 @@ vi.mock('../utils/federationOutbox.js', () => ({
getFriendEventTargets: () => [], getFriendEventTargets: () => [],
})); }));
vi.mock('../utils/federationAuth.js', () => ({ vi.mock('../utils/federationAuth.js', async (importActual) => {
getOurOrigin: () => 'https://local.test', const actual = await importActual<typeof import('../utils/federationAuth.js')>();
})); return { ...actual, getOurOrigin: () => 'https://local.test' };
});
function applyMigrations(db: Database.Database): void { function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle'); const migrationsDir = path.resolve(__dirname, '../../drizzle');
+287 -109
View File
@@ -1,11 +1,15 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { eq, and, or, ne, like, sql, inArray } from 'drizzle-orm'; import { eq, and, or, ne, like, sql, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js'; import { getDb, getRawDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js'; import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js'; import { generateSnowflake } from '../utils/snowflake.js';
import { connectionManager } from '../ws/handler.js'; import { connectionManager } from '../ws/handler.js';
import { appendMutationLog, queueOutboxEvent, buildFriendContextId, getFriendEventTargets } from '../utils/federationOutbox.js'; import { appendMutationLog, queueOutboxEvent, buildFriendContextId, getFriendEventTargets } from '../utils/federationOutbox.js';
import { getOurOrigin } from '../utils/federationAuth.js'; import { getOurOrigin, normalizeOriginForCompare } from '../utils/federationAuth.js';
import { ensurePeered } from '../utils/federationPeering.js';
import { lookupRemoteUser } from '../utils/federationLookup.js';
import { resolveOriginFromHostname } from '../utils/federationOriginResolve.js';
import { resolveOrCreateReplicatedUser, hydrateReplicatedUserProfile } from './federation.js';
import type { FederationRelayEvent, FederationRelayProfileSnapshot } from '@backspace/shared'; import type { FederationRelayEvent, FederationRelayProfileSnapshot } from '@backspace/shared';
import type { import type {
Friend, Friend,
@@ -27,6 +31,260 @@ 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 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<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);
// 6. Transaction: insert + log + queue outbox
const now = Date.now();
const fromIdentity = {
homeUserId: sender.homeUserId || sender.id,
homeInstance: ourOrigin,
};
const toIdentity = {
homeUserId: stubHydrated.homeUserId!,
homeInstance: peerOrigin,
};
const contextId = buildFriendContextId(fromIdentity.homeUserId, toIdentity.homeUserId);
const entityId = `friend_req:${[fromIdentity.homeUserId, toIdentity.homeUserId].sort().join(':')}:${now}`;
const requestId = generateSnowflake();
const payload: FederationRelayEvent = {
eventType: 'friend_request_create',
contextType: 'friend',
messageId: entityId,
encryptionVersion: 0,
timestamp: now,
friendship: {
from: fromIdentity,
to: toIdentity,
fromProfile: buildProfileSnapshot(sender),
toProfile: {
username: stubHydrated.username,
displayName: lookup.profile.displayName,
avatar: lookup.profile.avatar,
avatarColor: lookup.profile.avatarColor,
banner: lookup.profile.banner,
bio: lookup.profile.bio,
},
status: 'pending',
createdAt: now,
},
};
const payloadStr = JSON.stringify(payload);
const rawDb = getRawDb();
rawDb.transaction(() => {
db.insert(schema.friendRequests).values({
id: requestId,
fromId: sender.id,
toId: stubHydrated.id,
status: 'pending',
createdAt: now,
relayMessageId: entityId,
}).run();
appendMutationLog(entityId, contextId, 'friend_request_create', payloadStr, 'friend');
queueOutboxEvent(entityId, contextId, 'friend_request_create', payloadStr, [peerOrigin], 'friend');
})();
// 7. WS broadcast to sender's other tabs/devices
const requestSnapshot: FriendRequest = {
id: requestId,
fromId: sender.id,
toId: stubHydrated.id,
status: 'pending',
createdAt: now,
user: sanitizeUser(stubHydrated),
};
connectionManager.sendToUser(sender.id, { type: 'friend_request_sent', request: requestSnapshot });
return reply.code(201).send({ success: true, requestId });
}
// ─── Route registration ───────────────────────────────────────────────────────
export async function socialRoutes(app: FastifyInstance): Promise<void> { export async function socialRoutes(app: FastifyInstance): Promise<void> {
// GET /api/social/friends - List all friends // GET /api/social/friends - List all friends
app.get('/api/social/friends', { app.get('/api/social/friends', {
@@ -112,7 +370,7 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send(result); return reply.code(200).send(result);
}); });
// POST /api/social/requests - Send a friend request // POST /api/social/requests - Send a friend request (local or federated)
app.post<{ Body: SendFriendRequest }>('/api/social/requests', { app.post<{ Body: SendFriendRequest }>('/api/social/requests', {
preHandler: authenticate, preHandler: authenticate,
}, async (request, reply) => { }, async (request, reply) => {
@@ -120,116 +378,36 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
const db = getDb(); const db = getDb();
if (!username || typeof username !== 'string') { if (!username || typeof username !== 'string') {
return reply.code(400).send({ error: 'Username is required', statusCode: 400 }); return reply.code(400).send({ error: 'username_required', statusCode: 400 });
} }
// Match the canonical-lowercase form used by auth (auth.ts:32, 211, 256). const raw = username.trim();
const lookupUsername = username.trim().toLowerCase(); if (!raw) return reply.code(400).send({ error: 'username_required', statusCode: 400 });
if (!lookupUsername) {
return reply.code(400).send({ error: 'Username is required', statusCode: 400 }); const sender = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!sender) return reply.code(401).send({ error: 'authenticated user not found', statusCode: 401 });
const ourOrigin = getOurOrigin();
const ourHost = normalizeOriginForCompare(ourOrigin);
// Authority defense — only native users may originate friend_request_create
// outbox events from this instance (spec §5.6). Done before any branching.
const senderHomeNorm = normalizeOriginForCompare(sender.homeInstance);
if (senderHomeNorm && senderHomeNorm !== ourHost) {
return reply.code(403).send({ error: 'not_authoritative_for_sender', statusCode: 403 });
} }
// Find the target user const atIndex = raw.lastIndexOf('@');
const targetUser = db.select().from(schema.users).where(eq(schema.users.username, lookupUsername)).get(); const isFederated =
if (!targetUser) { atIndex > 0 &&
return reply.code(404).send({ error: 'User not found', statusCode: 404 }); atIndex < raw.length - 1 &&
normalizeOriginForCompare(raw.slice(atIndex + 1)) !== ourHost;
if (!isFederated) {
const localUsername = atIndex > 0 ? raw.slice(0, atIndex) : raw;
return handleLocalFriendRequest(db, request, reply, localUsername, sender, ourOrigin);
} }
return handleFederatedFriendRequest(db, request, reply, raw, atIndex, sender, ourOrigin);
if (targetUser.id === request.userId) {
return reply.code(400).send({ error: 'You cannot add yourself as a friend', statusCode: 400 });
}
// Check if already friends
const existingFriend = db.select().from(schema.friends).where(or(
and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, targetUser.id)),
and(eq(schema.friends.userId, targetUser.id), eq(schema.friends.friendId, request.userId))
)).get();
if (existingFriend) {
return reply.code(400).send({ error: 'You are already friends with this user', statusCode: 400 });
}
// Check for existing pending request
const existingRequest = db.select().from(schema.friendRequests).where(and(
or(
and(eq(schema.friendRequests.fromId, request.userId), eq(schema.friendRequests.toId, targetUser.id)),
and(eq(schema.friendRequests.fromId, targetUser.id), eq(schema.friendRequests.toId, request.userId))
),
eq(schema.friendRequests.status, 'pending')
)).get();
if (existingRequest) {
return reply.code(400).send({ error: 'A friend request is already pending', statusCode: 400 });
}
// Create the request
const id = generateSnowflake();
const now = Date.now();
db.insert(schema.friendRequests).values({
id,
fromId: request.userId,
toId: targetUser.id,
status: 'pending',
createdAt: now,
}).run();
// Get the sender user for the WS event
const senderUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
// Broadcast friend_request_received to the target user
const friendRequestPayload: FriendRequest = {
id,
fromId: request.userId,
toId: targetUser.id,
status: 'pending',
createdAt: now,
user: senderUser ? sanitizeUser(senderUser) : undefined,
};
connectionManager.sendToUser(targetUser.id, {
type: 'friend_request_received',
request: friendRequestPayload,
});
// Federation relay: notify the target user's home instance
const domainOrigin = getOurOrigin();
const fromIdentity = {
homeUserId: senderUser?.homeUserId || request.userId,
homeInstance: senderUser?.homeInstance || domainOrigin,
};
const toIdentity = {
homeUserId: targetUser.homeUserId || targetUser.id,
homeInstance: targetUser.homeInstance || domainOrigin,
};
const targets = getFriendEventTargets(fromIdentity.homeInstance, toIdentity.homeInstance);
if (targets.length > 0) {
const contextId = buildFriendContextId(fromIdentity.homeUserId, toIdentity.homeUserId);
const entityId = `friend_req:${[fromIdentity.homeUserId, toIdentity.homeUserId].sort().join(':')}:${now}`;
const payload: FederationRelayEvent = {
eventType: 'friend_request_create',
contextType: 'friend',
messageId: entityId,
encryptionVersion: 0,
timestamp: now,
friendship: {
from: fromIdentity,
to: toIdentity,
fromProfile: senderUser ? buildProfileSnapshot(senderUser) : undefined,
toProfile: buildProfileSnapshot(targetUser),
status: 'pending',
createdAt: now,
},
};
const payloadStr = JSON.stringify(payload);
appendMutationLog(entityId, contextId, 'friend_request_create', payloadStr, 'friend');
queueOutboxEvent(entityId, contextId, 'friend_request_create', payloadStr, targets, 'friend');
}
return reply.code(201).send({ success: true, requestId: id });
}); });
// PATCH /api/social/requests/:id - Accept/Decline a friend request // PATCH /api/social/requests/:id - Accept/Decline a friend request