From 867caa5f3169b8e00399e2646ba2b7d9fbf5fdf7 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:28:45 +0200 Subject: [PATCH 01/10] feat: add federation identity delete request/response types --- packages/shared/src/types.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index ceebe390..4da7fb05 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -706,6 +706,29 @@ export interface DeleteAccountRequest { username: string; // Must match — confirmation safeguard } +// ─── Federation Identity Delete Types ──────────────────────────────────── + +export interface FederationIdentityDeleteRequest { + origins: string[]; + mode: 'soft' | 'full'; +} + +export interface FederationIdentityDeleteResult { + success: boolean; + error?: string; + ownedSpaces?: { id: string; name: string }[]; +} + +export interface FederationIdentityDeleteResponse { + results: Record; +} + +export interface FederationIdentityDeleteS2SRequest { + homeUserId: string; + homeInstance: string; + mode: 'soft' | 'full'; +} + // ─── Storage Management Types ───────────────────────────────────────────── export interface StorageBreakdown { From bb4b031423f2a5c7e2cd72d869d7666e7594b106 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:30:00 +0200 Subject: [PATCH 02/10] feat: add purgeContent option to tombstoneUser for soft-delete mode --- packages/server/src/utils/userDeletion.ts | 73 +++++++++++++---------- 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/packages/server/src/utils/userDeletion.ts b/packages/server/src/utils/userDeletion.ts index bdc10e70..3d3b8bae 100644 --- a/packages/server/src/utils/userDeletion.ts +++ b/packages/server/src/utils/userDeletion.ts @@ -2,6 +2,11 @@ import crypto from 'crypto'; import { eq, or, and, inArray } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; +export interface TombstoneOptions { + /** When false, skip reaction deletion and orphaned DM purge (soft-delete mode). Default: true */ + purgeContent?: boolean; +} + /** * Tombstone a user account: removes them from all spaces, DMs, friends, * roles, reactions, folders, bans, voice restrictions, channel overrides, @@ -12,7 +17,7 @@ import { getDb, schema } from '../db/index.js'; * orphaned DM attachments). The caller is responsible for disk cleanup * and WebSocket disconnection after calling this. */ -export function tombstoneUser(uid: string): string[] { +export function tombstoneUser(uid: string, options?: TombstoneOptions): string[] { const db = getDb(); const user = db.select().from(schema.users).where(eq(schema.users.id, uid)).get(); @@ -22,6 +27,8 @@ export function tombstoneUser(uid: string): string[] { if (user.avatar) filesToDelete.push(user.avatar); if (user.banner) filesToDelete.push(user.banner); + const purge = options?.purgeContent !== false; // default true + // Find group DMs this user owns so we can transfer ownership const ownedGroupDms = db.select({ id: schema.dmChannels.id }) .from(schema.dmChannels) @@ -36,8 +43,10 @@ export function tombstoneUser(uid: string): string[] { tx.delete(schema.friendRequests).where(or(eq(schema.friendRequests.fromId, uid), eq(schema.friendRequests.toId, uid))).run(); tx.delete(schema.dmMembers).where(eq(schema.dmMembers.userId, uid)).run(); tx.delete(schema.readStates).where(eq(schema.readStates.userId, uid)).run(); - tx.delete(schema.reactions).where(eq(schema.reactions.userId, uid)).run(); - tx.delete(schema.dmReactions).where(eq(schema.dmReactions.userId, uid)).run(); + if (purge) { + tx.delete(schema.reactions).where(eq(schema.reactions.userId, uid)).run(); + tx.delete(schema.dmReactions).where(eq(schema.dmReactions.userId, uid)).run(); + } tx.delete(schema.spaceFolders).where(eq(schema.spaceFolders.userId, uid)).run(); // Conditional deletes for tables that may reference userId @@ -76,38 +85,40 @@ export function tombstoneUser(uid: string): string[] { } } - // Clean up orphaned DM channels (zero members after our removal) - const orphanedDmIds = tx.select({ id: schema.dmChannels.id }) - .from(schema.dmChannels) - .all() - .filter(dc => { - const memberCount = tx.select({ id: schema.dmMembers.dmChannelId }) - .from(schema.dmMembers) - .where(eq(schema.dmMembers.dmChannelId, dc.id)) - .all() - .length; - return memberCount === 0; - }) - .map(dc => dc.id); - - for (const dmId of orphanedDmIds) { - const msgIds = tx.select({ id: schema.dmMessages.id }) - .from(schema.dmMessages) - .where(eq(schema.dmMessages.dmChannelId, dmId)) + if (purge) { + // Clean up orphaned DM channels (zero members after our removal) + const orphanedDmIds = tx.select({ id: schema.dmChannels.id }) + .from(schema.dmChannels) .all() - .map(m => m.id); + .filter(dc => { + const memberCount = tx.select({ id: schema.dmMembers.dmChannelId }) + .from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, dc.id)) + .all() + .length; + return memberCount === 0; + }) + .map(dc => dc.id); - if (msgIds.length > 0) { - const dmAttachments = tx.select({ filename: schema.attachments.filename }) - .from(schema.attachments) - .where(inArray(schema.attachments.dmMessageId, msgIds)) - .all(); - for (const att of dmAttachments) filesToDelete.push(att.filename); + for (const dmId of orphanedDmIds) { + const msgIds = tx.select({ id: schema.dmMessages.id }) + .from(schema.dmMessages) + .where(eq(schema.dmMessages.dmChannelId, dmId)) + .all() + .map(m => m.id); - tx.delete(schema.attachments).where(inArray(schema.attachments.dmMessageId, msgIds)).run(); - tx.delete(schema.dmReactions).where(inArray(schema.dmReactions.dmMessageId, msgIds)).run(); + if (msgIds.length > 0) { + const dmAttachments = tx.select({ filename: schema.attachments.filename }) + .from(schema.attachments) + .where(inArray(schema.attachments.dmMessageId, msgIds)) + .all(); + for (const att of dmAttachments) filesToDelete.push(att.filename); + + tx.delete(schema.attachments).where(inArray(schema.attachments.dmMessageId, msgIds)).run(); + tx.delete(schema.dmReactions).where(inArray(schema.dmReactions.dmMessageId, msgIds)).run(); + } + tx.delete(schema.dmChannels).where(eq(schema.dmChannels.id, dmId)).run(); } - tx.delete(schema.dmChannels).where(eq(schema.dmChannels.id, dmId)).run(); } // Tombstone user row — rename username to free it for reuse From 4da373c970eb28216b99e18c3f3bcb8b3949d157 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:32:36 +0200 Subject: [PATCH 03/10] fix: prevent resolveOrCreateReplicatedUser from recreating deleted user stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a federated user's identity has been tombstoned (isDeleted=1), findFederatedUser filters them out, causing resolveOrCreateReplicatedUser to silently create a new stub — a "zombie" resurrection. This guard checks for a deleted row before creating a stub and returns null instead. All 11 call sites across federation.ts and dm.ts have been updated with appropriate null guards: federation relay handlers reject or skip the event (participant_not_found / accepted no-op), while dm.ts routes convert null to undefined so the existing 404 path handles it. --- packages/server/src/routes/dm.ts | 6 +- packages/server/src/routes/federation.ts | 72 ++++++++++++++++++++---- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index 223d6f9a..aaccc9f5 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -384,7 +384,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { if (homeUserId && homeInstance) { // Federated identity: resolve or create a replicated user stub - targetUser = resolveOrCreateReplicatedUser(homeUserId, homeInstance, db); + targetUser = resolveOrCreateReplicatedUser(homeUserId, homeInstance, db) ?? undefined; } else if (userId && typeof userId === 'string') { // Local ID: direct lookup (existing behavior) targetUser = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); @@ -570,7 +570,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { if (identity.homeUserId && identity.homeInstance) { // Federated user — resolve via homeUserId, creating a replicated stub if needed - localUser = resolveOrCreateReplicatedUser(identity.homeUserId, identity.homeInstance, db); + localUser = resolveOrCreateReplicatedUser(identity.homeUserId, identity.homeInstance, db) ?? undefined; } else { // Local user — direct ID lookup localUser = db.select().from(schema.users).where( @@ -865,7 +865,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { if (homeUserId && homeInstance) { // Federated identity: resolve or create a replicated user stub - targetUser = resolveOrCreateReplicatedUser(homeUserId, homeInstance, db); + targetUser = resolveOrCreateReplicatedUser(homeUserId, homeInstance, db) ?? undefined; } else if (targetUserIdRaw && typeof targetUserIdRaw === 'string') { // Local ID: direct lookup (existing behavior) targetUser = db.select().from(schema.users).where(eq(schema.users.id, targetUserIdRaw)).get(); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index febe32da..9c6e5f53 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -1397,10 +1397,23 @@ export function resolveOrCreateReplicatedUser( homeInstance: string, db: ReturnType, hints?: { username?: string | null }, -): typeof schema.users.$inferSelect { +): typeof schema.users.$inferSelect | null { const existing = findFederatedUser(homeUserId, homeInstance, db, hints); if (existing) return backfillHomeUserId(existing, homeUserId, db); + // Check if this identity was previously deleted — don't resurrect a tombstoned + // user by creating a new stub. The isDeleted=0 filter in findFederatedUser + // already hides the deleted row, so we must query without that filter here. + const deletedMatch = db + .select({ id: schema.users.id, isDeleted: schema.users.isDeleted }) + .from(schema.users) + .where(eq(schema.users.homeUserId, homeUserId)) + .get(); + if (deletedMatch?.isDeleted) { + console.log(`[federation] Skipping stub creation for deleted identity homeUserId=${homeUserId} (tombstoned)`); + return null; + } + // Normalize homeInstance to bare domain for consistent storage const domain = extractDomain(homeInstance); @@ -1612,6 +1625,8 @@ function processCreateEvent( for (const p of event.participants) { let localUser = resolveOrCreateReplicatedUser(p.homeUserId, p.homeInstance, db, { username: p.profile?.username }); + // Skip deleted identities — don't include tombstoned users in the DM + if (!localUser) continue; // Hydrate with profile data from the relay event (displayName, avatar, etc.) if (p.profile) { localUser = hydrateReplicatedUserProfile(localUser, p.profile, db); @@ -2175,7 +2190,7 @@ function processMemberAddEvent( let ownerId: string | null = null; if (event.group.owner) { const ownerLocal = resolveOrCreateReplicatedUser(event.group.owner.homeUserId, event.group.owner.homeInstance, db, { username: event.group.owner.profile?.username }); - ownerId = ownerLocal.id; + ownerId = ownerLocal?.id ?? null; } db.insert(schema.dmChannels) @@ -2193,6 +2208,8 @@ function processMemberAddEvent( // participants from remote instances that haven't been seen before. for (const member of event.group.members) { const localUser = resolveOrCreateReplicatedUser(member.homeUserId, member.homeInstance, db, { username: member.profile?.username }); + // Skip deleted identities — tombstoned users can't be added to a DM + if (!localUser) continue; const existing = db.select().from(schema.dmMembers) .where(and( eq(schema.dmMembers.dmChannelId, channelId), @@ -2281,6 +2298,11 @@ function processMemberAddEvent( db, { username: event.membership.user.profile?.username }, ); + if (!localUser) { + // The user's identity has been deleted — don't add a tombstoned user to the DM + rejected.push({ messageId: event.messageId, reason: 'participant_not_found' }); + return; + } // Enforce max 10 members const memberCount = db.select() @@ -2516,15 +2538,19 @@ function processOwnershipTransferEvent( return; } - // Resolve new owner to local user — use resolveOrCreateReplicatedUser to - // guarantee we always get a valid user ID. Never fall back to null, as that - // would convert the group DM into a 1-on-1 and destroy its type identity. + // Resolve new owner to local user. If the new owner's identity has been + // deleted, we cannot complete the transfer — reject so the event can be + // retried or dropped by the sender. const newOwnerLocal = resolveOrCreateReplicatedUser( event.ownership.newOwner.homeUserId, event.ownership.newOwner.homeInstance, db, { username: event.ownership.newOwner.profile?.username }, ); + if (!newOwnerLocal) { + rejected.push({ messageId: event.messageId, reason: 'participant_not_found' }); + return; + } db.update(schema.dmChannels) .set({ @@ -2654,8 +2680,13 @@ function processFriendRequestCreateEvent( } // Resolve the sender (create stub if needed — they're on a remote instance) - let fromUser = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username }); - fromUser = hydrateReplicatedUserProfile(fromUser, event.friendship.fromProfile, db); + const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username }); + if (!fromUserResolved) { + // Sender's identity has been deleted — silently accept to drop the event + accepted.push(event.messageId); + return; + } + let fromUser = hydrateReplicatedUserProfile(fromUserResolved, event.friendship.fromProfile, db); // Resolve the recipient — must be a local user on this instance const toUser = resolveLocalUser(to.homeUserId, db); @@ -2759,6 +2790,11 @@ function processFriendRequestUpdateEvent( // Resolve the recipient (create stub if needed — they're on the remote instance) const toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username }); + if (!toUser) { + // Recipient's identity has been deleted — accept idempotently to drop the event + accepted.push(event.messageId); + return; + } // Find the pending request const pendingRequest = db @@ -2893,10 +2929,19 @@ function processFriendAddEvent( } // Resolve both users (create stubs if needed) and hydrate with profile data - let fromUser = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username }); - fromUser = hydrateReplicatedUserProfile(fromUser, event.friendship.fromProfile, db); - let toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username }); - toUser = hydrateReplicatedUserProfile(toUser, event.friendship.toProfile, db); + const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username }); + if (!fromUserResolved) { + // One party's identity is deleted — accept idempotently to drop the event + accepted.push(event.messageId); + return; + } + let fromUser = hydrateReplicatedUserProfile(fromUserResolved, event.friendship.fromProfile, db); + const toUserResolved = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username }); + if (!toUserResolved) { + accepted.push(event.messageId); + return; + } + let toUser = hydrateReplicatedUserProfile(toUserResolved, event.friendship.toProfile, db); // Idempotency: if friendship already exists, accept as no-op const existingFriend = db @@ -3171,6 +3216,11 @@ function processDmCallStartEvent( db, { username: event.call.caller.displayName }, ); + if (!callerStub) { + // Caller's identity has been deleted — can't initiate a call as a tombstoned user + rejected.push({ messageId: event.messageId, reason: 'participant_not_found' }); + return; + } // Create federated call entry in registry const entry: FederatedCallEntry = { From 2c09953864e1f16858dc27a4e44af3abde44edae Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:35:06 +0200 Subject: [PATCH 04/10] fix: scope zombie guard by homeInstance to prevent cross-instance false matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit homeUserId snowflakes aren't globally unique — must also match homeInstance to avoid blocking stub creation for unrelated users. --- packages/server/src/routes/federation.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 9c6e5f53..1dd0bc5a 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -1404,19 +1404,17 @@ export function resolveOrCreateReplicatedUser( // Check if this identity was previously deleted — don't resurrect a tombstoned // user by creating a new stub. The isDeleted=0 filter in findFederatedUser // already hides the deleted row, so we must query without that filter here. + const domain = extractDomain(homeInstance); const deletedMatch = db .select({ id: schema.users.id, isDeleted: schema.users.isDeleted }) .from(schema.users) - .where(eq(schema.users.homeUserId, homeUserId)) + .where(and(eq(schema.users.homeUserId, homeUserId), eq(schema.users.homeInstance, domain))) .get(); if (deletedMatch?.isDeleted) { console.log(`[federation] Skipping stub creation for deleted identity homeUserId=${homeUserId} (tombstoned)`); return null; } - // Normalize homeInstance to bare domain for consistent storage - const domain = extractDomain(homeInstance); - // Use the snowflake-style homeUserId as the local part; append the // domain so the username is globally unique and human-readable. const baseUsername = `${homeUserId}@${domain}`.toLowerCase(); 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 05/10] 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) => { From 793a3967be5813f2a527857c8041e4a764229e58 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:38:42 +0200 Subject: [PATCH 06/10] feat: add DELETE /api/federation/identity S2S endpoint --- packages/server/src/routes/federation.ts | 102 ++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 1dd0bc5a..e7eacdfc 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -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 { }, ); + // ─── 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); + 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. From abd5ad145aa49c9ac46e8394da1dc2cecae9fd90 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:41:18 +0200 Subject: [PATCH 07/10] feat: add deleteFederationIdentity API client method --- packages/web/src/api/client.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index f9f4ab30..519a8876 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -50,6 +50,8 @@ import type { InvitePreview, GifResult, FederationRegistryEntry, + FederationIdentityDeleteRequest, + FederationIdentityDeleteResponse, } from '@backspace/shared'; export class RateLimitError extends Error { @@ -93,6 +95,7 @@ export class BackspaceApiClient { getMutuals: (id: string, homeUserId?: string) => Promise<{ mutualFriends: User[]; mutualSpaces: { id: string; name: string; icon: string | null; avatarColor: string | null }[] }>; getFederationRegistry: () => Promise<{ registry: FederationRegistryEntry[]; updatedAt: number }>; putFederationRegistry: (data: { registry: FederationRegistryEntry[]; updatedAt: number }) => Promise<{ ok: boolean; updatedAt: number }>; + deleteFederationIdentity: (data: FederationIdentityDeleteRequest) => Promise; }; readonly spaceLayout: { @@ -417,6 +420,10 @@ export class BackspaceApiClient { request<{ ok: boolean; updatedAt: number }>( 'PUT', '/users/@me/federation-registry', data ), + deleteFederationIdentity: (data: FederationIdentityDeleteRequest) => + request( + 'POST', '/users/@me/federation-identity/delete', data + ), }; this.spaceLayout = { From e7be58f01d81b871165385654ab5844acc144757 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:43:55 +0200 Subject: [PATCH 08/10] feat: implement deleteIdentity with S2S relay for soft/full modes --- .../components/modals/ConnectedInstances.tsx | 2 +- packages/web/src/stores/instanceStore.ts | 29 +++++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/web/src/components/modals/ConnectedInstances.tsx b/packages/web/src/components/modals/ConnectedInstances.tsx index 3a4a6c28..ebfccc85 100644 --- a/packages/web/src/components/modals/ConnectedInstances.tsx +++ b/packages/web/src/components/modals/ConnectedInstances.tsx @@ -427,7 +427,7 @@ function DeleteIdentityDialog({ const [scope, setScope] = useState('this'); const handleConfirm = () => { - deleteIdentity(origin); + deleteIdentity([origin]); onClose(); }; diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index 0e9d21e8..0653cffe 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -157,7 +157,7 @@ interface InstanceState { registry: Map; registryUpdatedAt: number; syncRegistry: () => Promise; - deleteIdentity: (origin: string) => void; + deleteIdentity: (origins: string[], mode?: 'leave' | 'soft' | 'full') => Promise>; forceRemoveEntry: (origin: string) => void; probeInstance: (url: string) => Promise; @@ -703,8 +703,31 @@ export const useInstanceStore = create((set, get) => ({ await Promise.all([homePromise, ...remotePromises]); }, - deleteIdentity: (_origin: string) => { - useUIStore.getState().addToast('Identity deletion is not yet implemented', 'info', 3000); + deleteIdentity: async (origins: string[], mode: 'leave' | 'soft' | 'full' = 'leave') => { + // Leave mode: client-only cleanup, no server call + if (mode === 'leave') { + for (const origin of origins) { + get().forceRemoveEntry(origin); + } + return Object.fromEntries(origins.map(o => [o, { success: true as const }])); + } + + // Soft/full mode: S2S relay via home instance + try { + const { results } = await api.users.deleteFederationIdentity({ origins, mode }); + + // Clean up client-side state for successful deletions + for (const [origin, result] of Object.entries(results)) { + if (result.success) { + get().forceRemoveEntry(origin); + } + } + + return results; + } catch (err) { + const error = err instanceof Error ? err.message : 'Unknown error'; + return Object.fromEntries(origins.map(o => [o, { success: false as const, error }])); + } }, forceRemoveEntry: (origin: string) => { From c918199cee2bbb2000623a4570e61f50d31370ae Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:48:35 +0200 Subject: [PATCH 09/10] feat: wire up DeleteIdentityDialog with S2S deletion modes and scope Update the dialog to support three deletion modes (leave/soft/full), scope selector with disabled "Select instances..." option, loading state during deletion, and per-instance error handling via toasts. --- .../components/modals/ConnectedInstances.tsx | 140 +++++++++++++----- 1 file changed, 107 insertions(+), 33 deletions(-) diff --git a/packages/web/src/components/modals/ConnectedInstances.tsx b/packages/web/src/components/modals/ConnectedInstances.tsx index ebfccc85..78770e66 100644 --- a/packages/web/src/components/modals/ConnectedInstances.tsx +++ b/packages/web/src/components/modals/ConnectedInstances.tsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom'; import type { InstanceInfoResponse, FederationRegistryEntry } from '@backspace/shared'; import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore'; import { useAuthStore } from '../../stores/authStore'; +import { useUIStore } from '../../stores/uiStore'; import { isElectron } from '../../platform/platform'; import { ConfirmDialog } from '../ui/ConfirmDialog'; @@ -410,7 +411,7 @@ function RegistryFilterBar({ // ─── DeleteIdentityDialog ─────────────────────────────────────────────────── -type DeletionMode = 'leave' | 'nuke' | 'evaporate'; +type DeletionMode = 'leave' | 'soft' | 'full'; type DeletionScope = 'this' | 'select' | 'all'; function DeleteIdentityDialog({ @@ -423,19 +424,77 @@ function DeleteIdentityDialog({ onClose: () => void; }) { const deleteIdentity = useInstanceStore((s) => s.deleteIdentity); + const registry = useInstanceStore((s) => s.registry); const [mode, setMode] = useState('leave'); const [scope, setScope] = useState('this'); + const [loading, setLoading] = useState(false); - const handleConfirm = () => { - deleteIdentity([origin]); - onClose(); + const handleConfirm = async () => { + // Resolve target origins based on scope + let targetOrigins: string[]; + if (scope === 'all') { + targetOrigins = Array.from(registry.keys()); + } else { + targetOrigins = [origin]; + } + + if (targetOrigins.length === 0) { + onClose(); + return; + } + + if (mode !== 'leave') { + setLoading(true); + } + + const results = await deleteIdentity(targetOrigins, mode); + + // Check results + const failed = Object.entries(results).filter(([, r]) => !r.success); + if (failed.length === 0) { + useUIStore.getState().addToast( + mode === 'leave' + ? 'Disconnected successfully' + : targetOrigins.length === 1 + ? 'Identity deleted successfully' + : `Identity deleted on ${targetOrigins.length} instances`, + 'success', + 3000, + ); + onClose(); + } else { + for (const [failOrigin, result] of failed) { + let host: string; + try { host = new URL(failOrigin).hostname; } catch { host = failOrigin; } + if (result.error === 'owns_spaces') { + useUIStore.getState().addToast( + `${host}: Transfer space ownership first`, + 'warning', + 5000, + ); + } else { + useUIStore.getState().addToast( + `${host}: ${result.error || 'Failed'}`, + 'warning', + 5000, + ); + } + } + // Close if some succeeded, keep open if all failed + const succeeded = Object.values(results).filter(r => r.success).length; + if (succeeded > 0) { + onClose(); + } else { + setLoading(false); + } + } }; return ReactDOM.createPortal(

Delete Identity

@@ -449,45 +508,54 @@ function DeleteIdentityDialog({ - {/* Nuke everything */} + {/* Delete User (soft) */} + + {/* Nuke everything (full) */} + - - {/* Evaporate (coming soon) */} -
-
Evaporate
-
- Gradually fade your presence — coming soon. -
-
{/* Scope selector */} @@ -495,18 +563,22 @@ function DeleteIdentityDialog({
Scope
{([ - { key: 'this' as DeletionScope, label: 'This instance only' }, - { key: 'select' as DeletionScope, label: 'Select instances...' }, - { key: 'all' as DeletionScope, label: 'All remote instances' }, + { key: 'this' as DeletionScope, label: 'This instance only', disabled: false }, + { key: 'select' as DeletionScope, label: 'Select instances...', disabled: true }, + { key: 'all' as DeletionScope, label: 'All remote instances', disabled: false }, ]).map((opt) => (
From 0ff20beedfbaf64a83d529a78d4e25138a09bbd2 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:51:50 +0200 Subject: [PATCH 10/10] docs: update subsystem docs for federation identity delete feature --- docs/systems/auth.md | 32 +++++++++++++++++++++++++++++++ docs/systems/client-federation.md | 14 ++++++++++++++ docs/systems/federation.md | 19 ++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/docs/systems/auth.md b/docs/systems/auth.md index b98d035d..a2cf3e73 100644 --- a/docs/systems/auth.md +++ b/docs/systems/auth.md @@ -364,6 +364,38 @@ isAdmin: 0 - Delete files from disk via `deleteUploadFile()` - `connectionManager.forceDisconnectUser()` -- closes all WS connections, leaves voice rooms, broadcasts presence +### `tombstoneUser()` Options + +`tombstoneUser` accepts an optional second argument: + +```typescript +interface TombstoneOptions { purgeContent?: boolean } +function tombstoneUser(uid: string, options?: TombstoneOptions): string[] +``` + +- **`purgeContent: true`** (default / omitted): full tombstone — existing behavior including reaction deletion and orphaned DM cleanup. +- **`purgeContent: false`**: soft tombstone — skips `reactions`, `dmReactions` deletion and orphaned DM channel purge. Used by the federation identity soft-delete endpoint so remote message history is retained. + +### `resolveOrCreateReplicatedUser` and Deleted Users + +`resolveOrCreateReplicatedUser` checks whether a user matching `homeUserId + homeInstance` already exists and has `isDeleted = 1`. If so, it returns `null` rather than returning or re-creating the deleted stub. This prevents zombie identities from reappearing after a federation identity deletion. + +### Federation Identity Deletion (Home-Side Trigger) + +**Endpoint:** `POST /api/users/@me/federation-identity/delete` +**Rate limit:** 5 requests / 15 minutes +**Auth:** JWT (`authenticate` preHandler) + +**Request body:** `{ origins: string[], mode: 'soft' | 'full' }` + +Fans out HMAC-signed `DELETE /api/federation/identity` requests to each listed remote in parallel. Returns a per-origin results map: + +```json +{ "results": { "": { "success": true } } } +``` + +On failure for a given origin the entry contains `{ "success": false, "error": "", "ownedSpaces"?: [...] }`. A `409` from a remote means the user owns spaces there that must be resolved before deletion can proceed. + ### `sanitizeUser()` for Deleted Users When `isDeleted === 1`, returns an anonymized profile: diff --git a/docs/systems/client-federation.md b/docs/systems/client-federation.md index 78df8e6d..a3a098f9 100644 --- a/docs/systems/client-federation.md +++ b/docs/systems/client-federation.md @@ -245,6 +245,20 @@ The **Connections** panel (in user settings) allows managing remote instance con - **Remote Instances** — each shows status (connected/disconnected/error), hostname, username. Actions: Reconnect, Re-authenticate, Sync Password, Disconnect. - **Add Instance** — multi-step form: enter hostname → verify password → register/login → connected. +### Identity Deletion + +Each remote instance row exposes an identity deletion flow with three modes: + +| Mode | Label | Behavior | +|------|-------|----------| +| `leave` | Leave quietly | Client-only disconnect; no server call. Registry entry removed locally. | +| `soft` | Delete User | S2S soft delete — anonymizes the remote account and removes memberships; message history is retained. | +| `full` | Nuke everything | S2S full tombstone — soft delete plus purge of DM data and reactions. | + +A scope selector controls which remotes are targeted: **This instance** (single remote) or **All remote instances** (fans out to every connected remote). A "Select instances" option is planned for future multi-select. + +Deletion is triggered via `POST /api/users/@me/federation-identity/delete` on the home instance (rate-limited 5/15 min). The home instance fans out HMAC-signed `DELETE /api/federation/identity` requests to each target remote in parallel and returns a per-origin results map `{ [origin]: { success, error?, ownedSpaces? } }`. If a remote reports owned spaces (`409`), the UI surfaces the space list so the user can resolve ownership before retrying. + --- ## 7. Federation Registry diff --git a/docs/systems/federation.md b/docs/systems/federation.md index 82d2072e..d85ab68e 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -116,6 +116,25 @@ Defined in `federationWorker.ts:45` as `10`. After 10 consecutive delivery failu | Endpoint | Method | Auth | Purpose | |----------|--------|------|---------| | `/api/federation/peer/rotate` | POST | HMAC | Accept secret rotation from peer | +| `/api/federation/identity` | DELETE | HMAC | Delete federated user identity (soft/full mode) | + +### S2S Identity Deletion (`DELETE /api/federation/identity`) + +Allows a home instance to remove a user's replicated identity from a remote instance. + +**Request body:** +```json +{ "homeUserId": "", "homeInstance": "", "mode": "soft" | "full" } +``` + +**Behavior:** + +- **Attribution guard:** Rejects with `403` if the user's `homeInstance` doesn't match the `X-Federation-Origin` of the signing peer. Prevents one instance from deleting another instance's users. +- **Idempotent:** Returns `{ success: true }` for already-deleted or nonexistent users (no error). +- **Owned spaces check:** Returns `409` with `{ ownedSpaces: string[] }` if the user owns any spaces on the remote. The user must transfer or delete those spaces before identity removal proceeds. +- **Mode `"soft"`:** Calls `tombstoneUser(uid, { purgeContent: false })` — anonymizes the user row and removes memberships, but skips reaction deletion and orphaned DM purge. +- **Mode `"full"`:** Calls `tombstoneUser(uid, { purgeContent: true })` — full tombstone including reactions and orphaned DM cleanup. +- **Post-deletion:** Broadcasts `member_left` WS events for all spaces the user belonged to before removal. ---