From 97470a4d3ac2e153be918a678f33d1b4fe9d2148 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:38:35 +0200 Subject: [PATCH] feat: add home-side federation identity delete trigger endpoint --- packages/server/src/routes/users.ts | 97 ++++++++++++++++++++++++++++- 1 file changed, 96 insertions(+), 1 deletion(-) diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index 0452791a..d99145a9 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -3,7 +3,7 @@ import { eq, or, and, inArray } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { authenticate, verifyPassword, hashPassword, signJwt } from '../utils/auth.js'; import { connectionManager } from '../ws/handler.js'; -import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance, SpaceLayoutItem, SpaceFolder, Activity } from '@backspace/shared'; +import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance, SpaceLayoutItem, SpaceFolder, Activity, FederationIdentityDeleteRequest, FederationIdentityDeleteResponse, FederationIdentityDeleteResult } from '@backspace/shared'; import { AVATAR_COLORS } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; import { deleteUploadFile, deleteAttachmentByFilename } from '../utils/fileCleanup.js'; @@ -11,6 +11,8 @@ import { tombstoneUser } from '../utils/userDeletion.js'; import { generateSnowflake } from '../utils/snowflake.js'; import { resizeProfileImage } from '../utils/thumbnail.js'; import { config } from '../config.js'; +import { buildFederationHeaders, getOurOrigin } from '../utils/federationAuth.js'; +import { extractDomain } from './federation.js'; import path from 'path'; /** Validates that a URL is a safe asset URL (relative upload path, bare filename, or http/https) */ @@ -579,6 +581,99 @@ export async function userRoutes(app: FastifyInstance): Promise { return reply.code(200).send({ ok: true, updatedAt }); }); + // POST /api/users/@me/federation-identity/delete — request identity deletion on remote instances via S2S + app.post<{ Body: FederationIdentityDeleteRequest }>('/api/users/@me/federation-identity/delete', { + preHandler: authenticate, + config: { rateLimit: { max: 5, timeWindow: '15 minutes' } }, + }, async (request, reply) => { + const { origins, mode } = request.body; + + if (!mode || !['soft', 'full'].includes(mode)) { + return reply.code(400).send({ error: 'Invalid mode: must be "soft" or "full"', statusCode: 400 }); + } + if (!Array.isArray(origins) || origins.length === 0 || !origins.every(o => typeof o === 'string')) { + return reply.code(400).send({ error: 'origins must be a non-empty array of strings', statusCode: 400 }); + } + + const db = getDb(); + const ourOrigin = getOurOrigin(); + const homeInstance = extractDomain(ourOrigin); + + const results: Record = {}; + + await Promise.all(origins.map(async (origin) => { + try { + // Look up peer + const peer = db + .select() + .from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, origin)) + .get(); + + if (!peer || peer.status !== 'active') { + results[origin] = { success: false, error: 'no_active_peer' }; + return; + } + + // Build HMAC-signed request + const body = JSON.stringify({ + homeUserId: request.userId, + homeInstance, + mode, + }); + + const headers = buildFederationHeaders(body, peer.hmacSecret, ourOrigin); + + // Send to remote with 15s timeout + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); + + try { + const response = await fetch(`${origin}/api/federation/identity`, { + method: 'DELETE', + headers, + body, + signal: controller.signal, + }); + + clearTimeout(timeout); + + const data = await response.json() as Record; + + if (response.ok) { + results[origin] = { success: true }; + } else if (data.error === 'owns_spaces') { + results[origin] = { + success: false, + error: 'owns_spaces', + ownedSpaces: data.ownedSpaces as { id: string; name: string }[], + }; + } else { + results[origin] = { + success: false, + error: (data.error as string) || `HTTP ${response.status}`, + }; + } + } catch (err) { + clearTimeout(timeout); + if (err instanceof Error && err.name === 'AbortError') { + results[origin] = { success: false, error: 'timeout' }; + } else { + results[origin] = { success: false, error: 'unreachable' }; + } + } + } catch (err) { + results[origin] = { + success: false, + error: err instanceof Error ? err.message : 'Unknown error', + }; + } + })); + + const response: FederationIdentityDeleteResponse = { results }; + return reply.code(200).send(response); + }); + // PUT /api/users/@me/space-layout — save sidebar layout (reorder, folders) app.put<{ Body: { items: SpaceLayoutItem[]; folders: Record; updatedAt?: number } }>( '/api/users/@me/space-layout', { preHandler: authenticate }, async (request, reply) => {