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.
This commit is contained in:
@@ -610,8 +610,8 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { origins, mode } = request.body;
|
const { origins, mode } = request.body;
|
||||||
|
|
||||||
if (!mode || !['soft', 'full'].includes(mode)) {
|
if (!mode || !['leave', 'soft', 'full'].includes(mode)) {
|
||||||
return reply.code(400).send({ error: 'Invalid mode: must be "soft" or "full"', statusCode: 400 });
|
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')) {
|
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 });
|
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<void> {
|
|||||||
|
|
||||||
await Promise.all(origins.map(async (origin) => {
|
await Promise.all(origins.map(async (origin) => {
|
||||||
try {
|
try {
|
||||||
// Look up peer
|
// Leave mode: no S2S call, just clean up the registry entry
|
||||||
const peer = db
|
if (mode === 'leave') {
|
||||||
.select()
|
results[origin] = { success: true };
|
||||||
.from(schema.federationPeers)
|
} else {
|
||||||
.where(eq(schema.federationPeers.origin, origin))
|
// Soft/full mode: S2S relay to remote instance
|
||||||
.get();
|
const peer = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.origin, origin))
|
||||||
|
.get();
|
||||||
|
|
||||||
if (!peer || peer.status !== 'active') {
|
if (!peer || peer.status !== 'active') {
|
||||||
results[origin] = { success: false, error: 'no_active_peer' };
|
results[origin] = { success: false, error: 'no_active_peer' };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build HMAC-signed request
|
const body = JSON.stringify({
|
||||||
const body = JSON.stringify({
|
homeUserId: request.userId,
|
||||||
homeUserId: request.userId,
|
homeInstance,
|
||||||
homeInstance,
|
mode,
|
||||||
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 headers = buildFederationHeaders(body, peer.hmacSecret, ourOrigin);
|
||||||
|
|
||||||
const data = await response.json() as Record<string, unknown>;
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 15_000);
|
||||||
|
|
||||||
if (response.ok) {
|
try {
|
||||||
results[origin] = { success: true };
|
const response = await fetch(`${origin}/api/federation/identity`, {
|
||||||
} else if (data.error === 'owns_spaces') {
|
method: 'DELETE',
|
||||||
results[origin] = {
|
headers,
|
||||||
success: false,
|
body,
|
||||||
error: 'owns_spaces',
|
signal: controller.signal,
|
||||||
ownedSpaces: data.ownedSpaces as { id: string; name: string }[],
|
});
|
||||||
};
|
|
||||||
} else {
|
clearTimeout(timeout);
|
||||||
results[origin] = {
|
|
||||||
success: false,
|
const data = await response.json() as Record<string, unknown>;
|
||||||
error: (data.error as string) || `HTTP ${response.status}`,
|
|
||||||
};
|
if (response.ok) {
|
||||||
}
|
results[origin] = { success: true };
|
||||||
} catch (err) {
|
} else if (data.error === 'owns_spaces') {
|
||||||
clearTimeout(timeout);
|
results[origin] = {
|
||||||
if (err instanceof Error && err.name === 'AbortError') {
|
success: false,
|
||||||
results[origin] = { success: false, error: 'timeout' };
|
error: 'owns_spaces',
|
||||||
} else {
|
ownedSpaces: data.ownedSpaces as { id: string; name: string }[],
|
||||||
results[origin] = { success: false, error: 'unreachable' };
|
};
|
||||||
|
} 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) {
|
} catch (err) {
|
||||||
results[origin] = {
|
results[origin] = {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -692,6 +710,27 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 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 };
|
const response: FederationIdentityDeleteResponse = { results };
|
||||||
return reply.code(200).send(response);
|
return reply.code(200).send(response);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -710,7 +710,7 @@ export interface DeleteAccountRequest {
|
|||||||
|
|
||||||
export interface FederationIdentityDeleteRequest {
|
export interface FederationIdentityDeleteRequest {
|
||||||
origins: string[];
|
origins: string[];
|
||||||
mode: 'soft' | 'full';
|
mode: 'leave' | 'soft' | 'full';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FederationIdentityDeleteResult {
|
export interface FederationIdentityDeleteResult {
|
||||||
|
|||||||
@@ -427,6 +427,7 @@ function DeleteIdentityDialog({
|
|||||||
const registry = useInstanceStore((s) => s.registry);
|
const registry = useInstanceStore((s) => s.registry);
|
||||||
const [mode, setMode] = useState<DeletionMode>('leave');
|
const [mode, setMode] = useState<DeletionMode>('leave');
|
||||||
const [scope, setScope] = useState<DeletionScope>('this');
|
const [scope, setScope] = useState<DeletionScope>('this');
|
||||||
|
const [selectedOrigins, setSelectedOrigins] = useState<Set<string>>(new Set());
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
@@ -434,6 +435,8 @@ function DeleteIdentityDialog({
|
|||||||
let targetOrigins: string[];
|
let targetOrigins: string[];
|
||||||
if (scope === 'all') {
|
if (scope === 'all') {
|
||||||
targetOrigins = Array.from(registry.keys());
|
targetOrigins = Array.from(registry.keys());
|
||||||
|
} else if (scope === 'select') {
|
||||||
|
targetOrigins = Array.from(selectedOrigins);
|
||||||
} else {
|
} else {
|
||||||
targetOrigins = [origin];
|
targetOrigins = [origin];
|
||||||
}
|
}
|
||||||
@@ -564,7 +567,7 @@ function DeleteIdentityDialog({
|
|||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
{([
|
{([
|
||||||
{ key: 'this' as DeletionScope, label: 'This instance only', disabled: false },
|
{ 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 },
|
{ key: 'all' as DeletionScope, label: 'All remote instances', disabled: false },
|
||||||
]).map((opt) => (
|
]).map((opt) => (
|
||||||
<button
|
<button
|
||||||
@@ -587,6 +590,37 @@ function DeleteIdentityDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Instance picker for 'select' scope */}
|
||||||
|
{scope === 'select' && (
|
||||||
|
<div className="mb-4 p-2 bg-surface-input rounded-lg max-h-40 overflow-y-auto scrollbar-thin space-y-0.5">
|
||||||
|
{Array.from(registry.values()).map((entry) => {
|
||||||
|
const checked = selectedOrigins.has(entry.origin);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={entry.origin}
|
||||||
|
className="flex items-center gap-2.5 px-2 py-1.5 rounded hover:bg-interactive-hover transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => {
|
||||||
|
setSelectedOrigins((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (checked) next.delete(entry.origin);
|
||||||
|
else next.add(entry.origin);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
className="accent-accent-lavender w-3.5 h-3.5 shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-txt-secondary truncate">{safeHost(entry.origin)}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
@@ -598,7 +632,7 @@ function DeleteIdentityDialog({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={loading}
|
disabled={loading || (scope === 'select' && selectedOrigins.size === 0)}
|
||||||
className="flex-1 py-2.5 bg-accent-rose hover:bg-accent-rose/80 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="flex-1 py-2.5 bg-accent-rose hover:bg-accent-rose/80 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{loading ? 'Deleting...' : mode === 'leave' ? 'Disconnect' : 'Delete Identity'}
|
{loading ? 'Deleting...' : mode === 'leave' ? 'Disconnect' : 'Delete Identity'}
|
||||||
|
|||||||
@@ -704,15 +704,6 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteIdentity: async (origins: string[], mode: 'leave' | 'soft' | 'full' = 'leave') => {
|
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 {
|
try {
|
||||||
const { results } = await api.users.deleteFederationIdentity({ origins, mode });
|
const { results } = await api.users.deleteFederationIdentity({ origins, mode });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user