feat: add home-side federation identity delete trigger endpoint

This commit is contained in:
Jannis Braun
2026-04-03 02:38:35 +02:00
parent 2c09953864
commit 97470a4d3a
+96 -1
View File
@@ -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<void> {
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<string, FederationIdentityDeleteResult> = {};
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<string, unknown>;
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<string, { name: string | null; color: string | null; spaceIds: string[] }>; updatedAt?: number } }>(
'/api/users/@me/space-layout', { preHandler: authenticate }, async (request, reply) => {