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:
@@ -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<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 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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<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> {
|
||||
// GET /api/social/friends - List all friends
|
||||
app.get('/api/social/friends', {
|
||||
@@ -112,7 +370,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 +378,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
|
||||
|
||||
Reference in New Issue
Block a user