feat(server): POST /api/dm/space-invite endpoint with rate-limited per-friend invite

Adds POST /api/dm/space-invite which fetches a space-invite snapshot
server-to-server from the space's home instance, ensures a 1-on-1 DM
between the caller and a friend, and posts a type='system' message
carrying SpaceInviteSystemPayload. Snapshot is never trusted from the
client. Rate-limited 30/60s per caller. Federation relay queued when the
recipient is on a remote instance (system message type forwarded by
Tasks 1-3).

Adds an `ensureOneOnOneDmChannel` helper that mirrors the dedup-or-create
behavior of the existing POST /api/dm handler — including federatedId
computation and the dm_channel_created notification payload — without
modifying that handler. Duplication is intentional; consolidation is a
separate follow-up.
This commit is contained in:
Jannis Braun
2026-04-29 21:37:44 +02:00
parent 855410f88c
commit 5cb0aa9d66
+252
View File
@@ -18,7 +18,11 @@ import {
type Attachment,
type Reaction,
type Embed,
type SpaceInviteRequest,
type SpaceInviteResponse,
type SpaceInviteSystemPayload,
} from '@backspace/shared';
import { fetchSpaceInviteSnapshot } from '../utils/spaceInviteSnapshot.js';
import { sanitizeUser } from '../utils/sanitize.js';
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
import { fetchDmEmbedsForMessages, resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js';
@@ -244,6 +248,143 @@ export function broadcastDmMessage(dmChannelId: string, message: DmMessageWithUs
}
}
/**
* Find the existing 1-on-1 DM channel between two users, reopening it if
* the caller had it soft-closed. Creates a new channel atomically when none
* exists. Returns the channel id.
*
* Mirrors the dedup-or-create behavior of the existing `POST /api/dm`
* handler, including:
* - skipping group DMs (ownerId !== null) and channels with !== 2 members
* - excluding soft-deleted channels
* - reopening on the caller side and queueing a `dm_reopen` relay
* - computing a deterministic `federatedId` when either party is federated
* - notifying the target on a fresh create with the same `dm_channel_created`
* payload shape (`id`, `ownerId`, `federatedId`, `createdAt`, `members`,
* `lastMessage: null`).
*
* NOTE: This helper is intentionally duplicated from `POST /api/dm` rather
* than refactored out of it — mixing a behavior-preserving rewrite of a
* heavily-used path with new feature work is the regression pattern this
* codebase avoids.
*/
export function ensureOneOnOneDmChannel(
callerId: string,
targetUser: typeof schema.users.$inferSelect,
db: ReturnType<typeof getDb>,
): string {
// 1. Look for an existing 1-on-1 channel between caller and target.
const callerMemberships = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.userId, callerId))
.all();
for (const membership of callerMemberships) {
const otherMember = db.select()
.from(schema.dmMembers)
.where(and(
eq(schema.dmMembers.dmChannelId, membership.dmChannelId),
eq(schema.dmMembers.userId, targetUser.id),
))
.get();
if (!otherMember) continue;
// Only match 1-on-1 DMs (exactly 2 members). Skip group DMs that
// happen to include the target user.
const memberCount = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, membership.dmChannelId))
.all()
.length;
if (memberCount !== 2) continue;
const dmChannel = db.select()
.from(schema.dmChannels)
.where(and(
eq(schema.dmChannels.id, membership.dmChannelId),
isNull(schema.dmChannels.deletedAt),
))
.get();
if (!dmChannel) continue;
if (dmChannel.ownerId !== null) continue; // belt-and-braces: skip group DMs
// Reopen if caller had it closed
if (membership.closed === 1) {
db.update(schema.dmMembers)
.set({ closed: 0 })
.where(and(
eq(schema.dmMembers.dmChannelId, membership.dmChannelId),
eq(schema.dmMembers.userId, callerId),
))
.run();
// Relay reopen to federated peers
queueDmCloseRelay(membership.dmChannelId, callerId, 'dm_reopen');
}
return dmChannel.id;
}
// 2. Create new channel atomically.
const dmChannelId = generateSnowflake();
const now = Date.now();
// Compute deterministic federatedId for federated 1-on-1 DMs so that the
// S2S relay can find this channel when the reply arrives, preventing duplicates.
let federatedId: string | null = null;
if (isFederationRelayEnabled()) {
const callerUser = db.select().from(schema.users).where(eq(schema.users.id, callerId)).get();
const callerHomeUserId = callerUser?.homeUserId || callerId;
const targetHomeUserId = targetUser.homeUserId || targetUser.id;
const callerHomeInstance = callerUser?.homeInstance || null;
const targetHomeInstance = targetUser.homeInstance || null;
if (callerHomeInstance || targetHomeInstance) {
federatedId = computeFederatedId(callerHomeUserId, targetHomeUserId);
}
}
db.transaction((tx) => {
tx.insert(schema.dmChannels).values({
id: dmChannelId,
ownerId: null,
federatedId,
createdAt: now,
}).run();
tx.insert(schema.dmMembers).values({
dmChannelId,
userId: callerId,
}).run();
tx.insert(schema.dmMembers).values({
dmChannelId,
userId: targetUser.id,
}).run();
});
// Notify the target so their sidebar updates — same payload shape as POST /api/dm.
const callerUser = db.select().from(schema.users).where(eq(schema.users.id, callerId)).get();
const members = [callerUser, targetUser]
.filter((u): u is NonNullable<typeof u> => u !== undefined)
.map(u => sanitizeUser(u));
const payload: DmChannel = {
id: dmChannelId,
ownerId: null,
federatedId: federatedId ?? null,
createdAt: now,
members,
lastMessage: null,
};
connectionManager.sendToUser(targetUser.id, {
type: 'dm_channel_created',
dmChannel: payload,
});
return dmChannelId;
}
export async function dmRoutes(app: FastifyInstance): Promise<void> {
// Centralized auth for all DM routes
app.addHook('preHandler', authenticate);
@@ -1537,6 +1678,117 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send(messages);
});
// POST /api/dm/space-invite — Send a space invite card to a friend via DM.
// Trust model: the snapshot is fetched server-to-server from the space's home
// instance via fetchSpaceInviteSnapshot — never trust client-supplied snapshot data.
app.post<{ Body: SpaceInviteRequest }>('/api/dm/space-invite', {
config: {
rateLimit: {
max: 30,
timeWindow: '60 seconds',
keyGenerator: (request: any) => request.userId || request.ip,
},
},
}, async (request, reply) => {
const body = request.body;
if (!body || !body.spaceId || !body.inviteCode || !body.target) {
return reply.code(400).send({ error: 'invalid_body', statusCode: 400 });
}
const db = getDb();
const callerId = request.userId;
// 1. Resolve target user (local id OR federated homeUserId+homeInstance)
let targetUser: typeof schema.users.$inferSelect | null = null;
if ('userId' in body.target) {
targetUser = db.select().from(schema.users)
.where(eq(schema.users.id, body.target.userId)).get() ?? null;
} else if ('homeUserId' in body.target && 'homeInstance' in body.target) {
targetUser = resolveOrCreateReplicatedUser(
body.target.homeUserId,
body.target.homeInstance,
db,
);
} else {
return reply.code(400).send({ error: 'invalid_target', statusCode: 400 });
}
if (!targetUser) {
return reply.code(404).send({ error: 'user_not_found', statusCode: 404 });
}
if (targetUser.id === callerId) {
return reply.code(400).send({ error: 'cannot_invite_self', statusCode: 400 });
}
// 2. Friendship check (anti-spam, NOT a permission gate — see spec).
const friendship = db.select().from(schema.friends).where(
or(
and(eq(schema.friends.userId, callerId), eq(schema.friends.friendId, targetUser.id)),
and(eq(schema.friends.userId, targetUser.id), eq(schema.friends.friendId, callerId)),
),
).get();
if (!friendship) {
return reply.code(400).send({ error: 'not_a_friend', statusCode: 400 });
}
// 3. Server-to-server snapshot fetch from the space's home instance.
const ourOrigin = getOurOrigin();
const spaceOrigin = body.spaceInstanceOrigin || ourOrigin;
const snapshot = await fetchSpaceInviteSnapshot(spaceOrigin, body.inviteCode);
if (!snapshot) {
return reply.code(400).send({ error: 'invite_invalid', statusCode: 400 });
}
if (snapshot.spaceId !== body.spaceId) {
// spaceId mismatch — code points at a different space than the client claimed.
return reply.code(400).send({ error: 'invite_invalid', statusCode: 400 });
}
// 4. Resolve / create the 1-on-1 DM (delegate to dedup helper).
const dmChannelId = ensureOneOnOneDmChannel(callerId, targetUser, db);
// 5. Build content + insert system message.
const now = Date.now();
const messageId = generateSnowflake();
const payload: SpaceInviteSystemPayload = {
event: 'space_invite',
spaceId: body.spaceId,
spaceInstanceOrigin: body.spaceInstanceOrigin,
inviteCode: body.inviteCode,
snapshot: {
spaceName: snapshot.spaceName,
icon: snapshot.icon,
avatarColor: snapshot.avatarColor,
memberCount: snapshot.memberCount,
description: snapshot.description,
instanceName: snapshot.instanceName,
},
};
db.insert(schema.dmMessages).values({
id: messageId,
dmChannelId,
userId: callerId,
content: JSON.stringify(payload),
type: 'system',
createdAt: now,
}).run();
// 6. Hydrate + broadcast locally.
const message = getDmMessageWithUser(messageId);
if (!message) {
return reply.code(500).send({ error: 'message_lookup_failed', statusCode: 500 });
}
broadcastDmMessage(dmChannelId, message);
// 7. Federation relay if the recipient is on a remote instance.
if (isFederationRelayEnabled() && targetUser.homeInstance && targetUser.homeInstance !== ourOrigin) {
queueDmRelay(message, dmChannelId, 'create');
}
const response: SpaceInviteResponse = { dmChannelId, messageId, message };
return reply.code(200).send(response);
});
// POST /api/dm/:id/messages - Send a DM message
app.post<{ Params: { id: string }; Body: CreateDmMessageRequest }>('/api/dm/:id/messages', {
config: {