From 02a44c201dcd51b69416e2219dd85f1fc287de50 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 3 Apr 2026 04:56:25 +0200 Subject: [PATCH] fix: clean up replicatedInstances and registry on federation identity deletion After deleting a federated identity, the server-side user_federation_registry and users.replicated_instances were not cleaned up, causing "already connected" errors when trying to re-federate. The deletion endpoint now authoritatively removes both the registry row and the replicatedInstances entry, and bumps the LWW timestamp to prevent stale client syncs from re-inserting them. Also extends the endpoint to accept mode 'leave' (skip S2S, just clean up), and enables the "Select instances..." scope option in DeleteIdentityDialog. --- packages/server/src/routes/users.ts | 145 +++++++++++------- packages/shared/src/types.ts | 2 +- .../components/modals/ConnectedInstances.tsx | 38 ++++- packages/web/src/stores/instanceStore.ts | 9 -- 4 files changed, 129 insertions(+), 65 deletions(-) diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index 077fc5a2..b1fd04c5 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -610,8 +610,8 @@ export async function userRoutes(app: FastifyInstance): Promise { }, 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 (!mode || !['leave', 'soft', 'full'].includes(mode)) { + return reply.code(400).send({ error: 'Invalid mode: must be "leave", "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 }); @@ -625,65 +625,83 @@ export async function userRoutes(app: FastifyInstance): Promise { 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(); + // Leave mode: no S2S call, just clean up the registry entry + if (mode === 'leave') { + results[origin] = { success: true }; + } else { + // Soft/full mode: S2S relay to remote instance + 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; - } + 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, + const body = JSON.stringify({ + homeUserId: request.userId, + homeInstance, + mode, }); - clearTimeout(timeout); + const headers = buildFederationHeaders(body, peer.hmacSecret, ourOrigin); - const data = await response.json() as Record; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); - 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' }; + 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' }; + } } } + + // On success, authoritatively remove the registry entry and bump LWW timestamp + if (results[origin]?.success) { + db.delete(schema.userFederationRegistry) + .where(and( + eq(schema.userFederationRegistry.userId, request.userId), + eq(schema.userFederationRegistry.origin, origin), + )) + .run(); + + db.update(schema.users) + .set({ federationRegistryUpdatedAt: Date.now() }) + .where(eq(schema.users.id, request.userId)) + .run(); + } } catch (err) { results[origin] = { success: false, @@ -692,6 +710,27 @@ export async function userRoutes(app: FastifyInstance): Promise { } })); + // Remove successful origins from the user's replicatedInstances JSON column + const successfulOrigins = Object.entries(results) + .filter(([, r]) => r.success) + .map(([o]) => o); + + if (successfulOrigins.length > 0) { + const user = db.select({ replicatedInstances: schema.users.replicatedInstances }) + .from(schema.users) + .where(eq(schema.users.id, request.userId)) + .get(); + + if (user?.replicatedInstances) { + const parsed: ReplicatedInstance[] = JSON.parse(user.replicatedInstances); + const filtered = parsed.filter(ri => !successfulOrigins.includes(ri.origin)); + db.update(schema.users) + .set({ replicatedInstances: JSON.stringify(filtered) }) + .where(eq(schema.users.id, request.userId)) + .run(); + } + } + const response: FederationIdentityDeleteResponse = { results }; return reply.code(200).send(response); }); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 4da7fb05..32aab956 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -710,7 +710,7 @@ export interface DeleteAccountRequest { export interface FederationIdentityDeleteRequest { origins: string[]; - mode: 'soft' | 'full'; + mode: 'leave' | 'soft' | 'full'; } export interface FederationIdentityDeleteResult { diff --git a/packages/web/src/components/modals/ConnectedInstances.tsx b/packages/web/src/components/modals/ConnectedInstances.tsx index c4c69fbc..15de7978 100644 --- a/packages/web/src/components/modals/ConnectedInstances.tsx +++ b/packages/web/src/components/modals/ConnectedInstances.tsx @@ -427,6 +427,7 @@ function DeleteIdentityDialog({ const registry = useInstanceStore((s) => s.registry); const [mode, setMode] = useState('leave'); const [scope, setScope] = useState('this'); + const [selectedOrigins, setSelectedOrigins] = useState>(new Set()); const [loading, setLoading] = useState(false); const handleConfirm = async () => { @@ -434,6 +435,8 @@ function DeleteIdentityDialog({ let targetOrigins: string[]; if (scope === 'all') { targetOrigins = Array.from(registry.keys()); + } else if (scope === 'select') { + targetOrigins = Array.from(selectedOrigins); } else { targetOrigins = [origin]; } @@ -564,7 +567,7 @@ function DeleteIdentityDialog({
{([ { key: 'this' as DeletionScope, label: 'This instance only', disabled: false }, - { key: 'select' as DeletionScope, label: 'Select instances...', disabled: true }, + { key: 'select' as DeletionScope, label: 'Select instances...', disabled: false }, { key: 'all' as DeletionScope, label: 'All remote instances', disabled: false }, ]).map((opt) => (
+ {/* Instance picker for 'select' scope */} + {scope === 'select' && ( +
+ {Array.from(registry.values()).map((entry) => { + const checked = selectedOrigins.has(entry.origin); + return ( + + ); + })} +
+ )} + {/* Actions */}