feat: add DELETE /api/federation/identity S2S endpoint

This commit is contained in:
Jannis Braun
2026-04-03 02:38:42 +02:00
parent 97470a4d3a
commit 793a3967be
+101 -1
View File
@@ -10,9 +10,10 @@ import { connectionManager } from '../ws/handler.js';
import type { FederatedCallEntry, DmRoomMeta } from '../ws/handler.js';
import { sanitizeUser } from '../utils/sanitize.js';
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
import { tombstoneUser } from '../utils/userDeletion.js';
import { computeFederatedId, getDmParticipants, sendCallRelay } from '../utils/federationOutbox.js';
import { getDmMessageWithUser } from './dm.js';
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, FederationRelayProfileSnapshot } from '@backspace/shared';
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, FederationRelayProfileSnapshot, FederationIdentityDeleteS2SRequest } from '@backspace/shared';
/** Fields safe to expose to admin callers (everything except hmacSecret). */
interface SanitizedPeer {
@@ -667,6 +668,105 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
},
);
// ─── DELETE /api/federation/identity ──────────────────────────────────────
// S2S endpoint: delete a federated user's identity on this instance.
// Called by the user's home instance via HMAC-signed request.
app.delete<{ Body: FederationIdentityDeleteS2SRequest }>(
'/api/federation/identity',
async (request, reply) => {
const db = getDb();
// 1. Verify HMAC signature (same pattern as 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 });
}
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 });
}
// 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 });
} else {
console.warn(`[federation] Peer ${peer.origin} does not support replay protection (no nonce)`);
}
// 2. Validate body
const { homeUserId, homeInstance, mode } = request.body;
if (!homeUserId || !homeInstance || !['soft', 'full'].includes(mode)) {
return reply.code(400).send({ error: 'Invalid request: homeUserId, homeInstance, and mode (soft|full) required', statusCode: 400 });
}
// 3. Resolve federated user — query directly (not resolveLocalUser which filters isDeleted)
const user = db.select().from(schema.users).where(eq(schema.users.homeUserId, homeUserId)).get();
// Idempotent: already deleted or never existed
if (!user || user.isDeleted) {
return reply.code(200).send({ success: true });
}
// 4. Attribution guard: only the user's home instance can delete them
if (!user.homeInstance || extractDomain(user.homeInstance) !== extractDomain(fedHeaders.origin)) {
return reply.code(403).send({ error: 'Attribution mismatch: you can only delete users from your own instance', statusCode: 403 });
}
// 5. Check for owned spaces
const ownedSpaces = db.select({ id: schema.spaces.id, name: schema.spaces.name })
.from(schema.spaces)
.where(eq(schema.spaces.ownerId, user.id))
.all();
if (ownedSpaces.length > 0) {
return reply.code(409).send({ error: 'owns_spaces', ownedSpaces, statusCode: 409 });
}
// 6. Collect spaces for broadcast BEFORE deletion removes memberships
const memberSpaceIds = db.select({ spaceId: schema.spaceMembers.spaceId })
.from(schema.spaceMembers)
.where(eq(schema.spaceMembers.userId, user.id))
.all()
.map(m => m.spaceId);
// 7. Execute deletion
const filesToDelete = tombstoneUser(user.id, { purgeContent: mode === 'full' });
// 8. Clean up files from disk
deleteAttachmentFiles(filesToDelete.map(f => ({ filename: f })));
// 9. Force-disconnect WS if somehow still connected (unlikely but safe)
connectionManager.forceDisconnectUser(user.id);
// 10. Broadcast member_left to other connected clients for each space
for (const spaceId of memberSpaceIds) {
connectionManager.sendToSpace(spaceId, {
type: 'member_left',
spaceId,
userId: user.id,
});
}
console.log(`[federation] Identity deleted for user ${user.id} (${user.username}) via S2S from ${fedHeaders.origin}, mode=${mode}`);
return reply.code(200).send({ success: true });
},
);
// ─── POST /api/federation/relay ────────────────────────────────────────────
// Server-to-server: receive relayed DM events from a peer instance.
// Authenticated via HMAC-SHA256 signature, NOT JWT.