feat: federation identity delete via S2S relay

Three deletion modes for federated identities:
- Leave quietly: client-only disconnect
- Delete User: S2S soft-delete (anonymize, keep messages)
- Nuke everything: S2S full tombstone (purge DM data)

New endpoints:
- DELETE /api/federation/identity (HMAC-authenticated S2S)
- POST /api/users/@me/federation-identity/delete (home-side trigger)

Also: tombstoneUser purgeContent option, zombie stub prevention,
updated dialog UI with scope selector.
This commit is contained in:
Jannis Braun
2026-04-03 02:56:48 +02:00
11 changed files with 530 additions and 84 deletions
+32
View File
@@ -364,6 +364,38 @@ isAdmin: 0
- Delete files from disk via `deleteUploadFile()` - Delete files from disk via `deleteUploadFile()`
- `connectionManager.forceDisconnectUser()` -- closes all WS connections, leaves voice rooms, broadcasts presence - `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": { "<origin>": { "success": true } } }
```
On failure for a given origin the entry contains `{ "success": false, "error": "<message>", "ownedSpaces"?: [...] }`. A `409` from a remote means the user owns spaces there that must be resolved before deletion can proceed.
### `sanitizeUser()` for Deleted Users ### `sanitizeUser()` for Deleted Users
When `isDeleted === 1`, returns an anonymized profile: When `isDeleted === 1`, returns an anonymized profile:
+14
View File
@@ -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. - **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. - **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 ## 7. Federation Registry
+19
View File
@@ -116,6 +116,25 @@ Defined in `federationWorker.ts:45` as `10`. After 10 consecutive delivery failu
| Endpoint | Method | Auth | Purpose | | Endpoint | Method | Auth | Purpose |
|----------|--------|------|---------| |----------|--------|------|---------|
| `/api/federation/peer/rotate` | POST | HMAC | Accept secret rotation from peer | | `/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": "<string>", "homeInstance": "<string>", "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.
--- ---
+3 -3
View File
@@ -384,7 +384,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
if (homeUserId && homeInstance) { if (homeUserId && homeInstance) {
// Federated identity: resolve or create a replicated user stub // 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') { } else if (userId && typeof userId === 'string') {
// Local ID: direct lookup (existing behavior) // Local ID: direct lookup (existing behavior)
targetUser = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); targetUser = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
@@ -570,7 +570,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
if (identity.homeUserId && identity.homeInstance) { if (identity.homeUserId && identity.homeInstance) {
// Federated user — resolve via homeUserId, creating a replicated stub if needed // 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 { } else {
// Local user — direct ID lookup // Local user — direct ID lookup
localUser = db.select().from(schema.users).where( localUser = db.select().from(schema.users).where(
@@ -865,7 +865,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
if (homeUserId && homeInstance) { if (homeUserId && homeInstance) {
// Federated identity: resolve or create a replicated user stub // 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') { } else if (targetUserIdRaw && typeof targetUserIdRaw === 'string') {
// Local ID: direct lookup (existing behavior) // Local ID: direct lookup (existing behavior)
targetUser = db.select().from(schema.users).where(eq(schema.users.id, targetUserIdRaw)).get(); targetUser = db.select().from(schema.users).where(eq(schema.users.id, targetUserIdRaw)).get();
+161 -13
View File
@@ -10,9 +10,10 @@ import { connectionManager } from '../ws/handler.js';
import type { FederatedCallEntry, DmRoomMeta } from '../ws/handler.js'; import type { FederatedCallEntry, DmRoomMeta } from '../ws/handler.js';
import { sanitizeUser } from '../utils/sanitize.js'; import { sanitizeUser } from '../utils/sanitize.js';
import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
import { tombstoneUser } from '../utils/userDeletion.js';
import { computeFederatedId, getDmParticipants, sendCallRelay } from '../utils/federationOutbox.js'; import { computeFederatedId, getDmParticipants, sendCallRelay } from '../utils/federationOutbox.js';
import { getDmMessageWithUser } from './dm.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). */ /** Fields safe to expose to admin callers (everything except hmacSecret). */
interface SanitizedPeer { interface SanitizedPeer {
@@ -667,6 +668,105 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
}, },
); );
// ─── 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<string, string | string[] | undefined>);
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 ──────────────────────────────────────────── // ─── POST /api/federation/relay ────────────────────────────────────────────
// Server-to-server: receive relayed DM events from a peer instance. // Server-to-server: receive relayed DM events from a peer instance.
// Authenticated via HMAC-SHA256 signature, NOT JWT. // Authenticated via HMAC-SHA256 signature, NOT JWT.
@@ -1397,12 +1497,23 @@ export function resolveOrCreateReplicatedUser(
homeInstance: string, homeInstance: string,
db: ReturnType<typeof getDb>, db: ReturnType<typeof getDb>,
hints?: { username?: string | null }, hints?: { username?: string | null },
): typeof schema.users.$inferSelect { ): typeof schema.users.$inferSelect | null {
const existing = findFederatedUser(homeUserId, homeInstance, db, hints); const existing = findFederatedUser(homeUserId, homeInstance, db, hints);
if (existing) return backfillHomeUserId(existing, homeUserId, db); if (existing) return backfillHomeUserId(existing, homeUserId, db);
// Normalize homeInstance to bare domain for consistent storage // 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 domain = extractDomain(homeInstance);
const deletedMatch = db
.select({ id: schema.users.id, isDeleted: schema.users.isDeleted })
.from(schema.users)
.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;
}
// Use the snowflake-style homeUserId as the local part; append the // Use the snowflake-style homeUserId as the local part; append the
// domain so the username is globally unique and human-readable. // domain so the username is globally unique and human-readable.
@@ -1612,6 +1723,8 @@ function processCreateEvent(
for (const p of event.participants) { for (const p of event.participants) {
let localUser = resolveOrCreateReplicatedUser(p.homeUserId, p.homeInstance, db, { username: p.profile?.username }); 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.) // Hydrate with profile data from the relay event (displayName, avatar, etc.)
if (p.profile) { if (p.profile) {
localUser = hydrateReplicatedUserProfile(localUser, p.profile, db); localUser = hydrateReplicatedUserProfile(localUser, p.profile, db);
@@ -2175,7 +2288,7 @@ function processMemberAddEvent(
let ownerId: string | null = null; let ownerId: string | null = null;
if (event.group.owner) { if (event.group.owner) {
const ownerLocal = resolveOrCreateReplicatedUser(event.group.owner.homeUserId, event.group.owner.homeInstance, db, { username: event.group.owner.profile?.username }); 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) db.insert(schema.dmChannels)
@@ -2193,6 +2306,8 @@ function processMemberAddEvent(
// participants from remote instances that haven't been seen before. // participants from remote instances that haven't been seen before.
for (const member of event.group.members) { for (const member of event.group.members) {
const localUser = resolveOrCreateReplicatedUser(member.homeUserId, member.homeInstance, db, { username: member.profile?.username }); 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) const existing = db.select().from(schema.dmMembers)
.where(and( .where(and(
eq(schema.dmMembers.dmChannelId, channelId), eq(schema.dmMembers.dmChannelId, channelId),
@@ -2281,6 +2396,11 @@ function processMemberAddEvent(
db, db,
{ username: event.membership.user.profile?.username }, { 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 // Enforce max 10 members
const memberCount = db.select() const memberCount = db.select()
@@ -2516,15 +2636,19 @@ function processOwnershipTransferEvent(
return; return;
} }
// Resolve new owner to local user — use resolveOrCreateReplicatedUser to // Resolve new owner to local user. If the new owner's identity has been
// guarantee we always get a valid user ID. Never fall back to null, as that // deleted, we cannot complete the transfer — reject so the event can be
// would convert the group DM into a 1-on-1 and destroy its type identity. // retried or dropped by the sender.
const newOwnerLocal = resolveOrCreateReplicatedUser( const newOwnerLocal = resolveOrCreateReplicatedUser(
event.ownership.newOwner.homeUserId, event.ownership.newOwner.homeUserId,
event.ownership.newOwner.homeInstance, event.ownership.newOwner.homeInstance,
db, db,
{ username: event.ownership.newOwner.profile?.username }, { username: event.ownership.newOwner.profile?.username },
); );
if (!newOwnerLocal) {
rejected.push({ messageId: event.messageId, reason: 'participant_not_found' });
return;
}
db.update(schema.dmChannels) db.update(schema.dmChannels)
.set({ .set({
@@ -2654,8 +2778,13 @@ function processFriendRequestCreateEvent(
} }
// Resolve the sender (create stub if needed — they're on a remote instance) // 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 }); const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username });
fromUser = hydrateReplicatedUserProfile(fromUser, event.friendship.fromProfile, db); 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 // Resolve the recipient — must be a local user on this instance
const toUser = resolveLocalUser(to.homeUserId, db); const toUser = resolveLocalUser(to.homeUserId, db);
@@ -2759,6 +2888,11 @@ function processFriendRequestUpdateEvent(
// Resolve the recipient (create stub if needed — they're on the remote instance) // 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 }); 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 // Find the pending request
const pendingRequest = db const pendingRequest = db
@@ -2893,10 +3027,19 @@ function processFriendAddEvent(
} }
// Resolve both users (create stubs if needed) and hydrate with profile data // 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 }); const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username });
fromUser = hydrateReplicatedUserProfile(fromUser, event.friendship.fromProfile, db); if (!fromUserResolved) {
let toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username }); // One party's identity is deleted — accept idempotently to drop the event
toUser = hydrateReplicatedUserProfile(toUser, event.friendship.toProfile, db); 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 // Idempotency: if friendship already exists, accept as no-op
const existingFriend = db const existingFriend = db
@@ -3171,6 +3314,11 @@ function processDmCallStartEvent(
db, db,
{ username: event.call.caller.displayName }, { 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 // Create federated call entry in registry
const entry: FederatedCallEntry = { const entry: FederatedCallEntry = {
+96 -1
View File
@@ -3,7 +3,7 @@ import { eq, or, and, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js'; import { getDb, schema } from '../db/index.js';
import { authenticate, verifyPassword, hashPassword, signJwt } from '../utils/auth.js'; import { authenticate, verifyPassword, hashPassword, signJwt } from '../utils/auth.js';
import { connectionManager } from '../ws/handler.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 { AVATAR_COLORS } from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js'; import { sanitizeUser } from '../utils/sanitize.js';
import { deleteUploadFile, deleteAttachmentByFilename } from '../utils/fileCleanup.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 { generateSnowflake } from '../utils/snowflake.js';
import { resizeProfileImage } from '../utils/thumbnail.js'; import { resizeProfileImage } from '../utils/thumbnail.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { buildFederationHeaders, getOurOrigin } from '../utils/federationAuth.js';
import { extractDomain } from './federation.js';
import path from 'path'; import path from 'path';
/** Validates that a URL is a safe asset URL (relative upload path, bare filename, or http/https) */ /** 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 }); 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) // 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 } }>( 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) => { '/api/users/@me/space-layout', { preHandler: authenticate }, async (request, reply) => {
+42 -31
View File
@@ -2,6 +2,11 @@ import crypto from 'crypto';
import { eq, or, and, inArray } from 'drizzle-orm'; import { eq, or, and, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js'; 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, * Tombstone a user account: removes them from all spaces, DMs, friends,
* roles, reactions, folders, bans, voice restrictions, channel overrides, * 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 * orphaned DM attachments). The caller is responsible for disk cleanup
* and WebSocket disconnection after calling this. * and WebSocket disconnection after calling this.
*/ */
export function tombstoneUser(uid: string): string[] { export function tombstoneUser(uid: string, options?: TombstoneOptions): string[] {
const db = getDb(); const db = getDb();
const user = db.select().from(schema.users).where(eq(schema.users.id, uid)).get(); 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.avatar) filesToDelete.push(user.avatar);
if (user.banner) filesToDelete.push(user.banner); 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 // Find group DMs this user owns so we can transfer ownership
const ownedGroupDms = db.select({ id: schema.dmChannels.id }) const ownedGroupDms = db.select({ id: schema.dmChannels.id })
.from(schema.dmChannels) .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.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.dmMembers).where(eq(schema.dmMembers.userId, uid)).run();
tx.delete(schema.readStates).where(eq(schema.readStates.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(); if (purge) {
tx.delete(schema.dmReactions).where(eq(schema.dmReactions.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();
}
tx.delete(schema.spaceFolders).where(eq(schema.spaceFolders.userId, uid)).run(); tx.delete(schema.spaceFolders).where(eq(schema.spaceFolders.userId, uid)).run();
// Conditional deletes for tables that may reference userId // 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) if (purge) {
const orphanedDmIds = tx.select({ id: schema.dmChannels.id }) // Clean up orphaned DM channels (zero members after our removal)
.from(schema.dmChannels) const orphanedDmIds = tx.select({ id: schema.dmChannels.id })
.all() .from(schema.dmChannels)
.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))
.all() .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) { for (const dmId of orphanedDmIds) {
const dmAttachments = tx.select({ filename: schema.attachments.filename }) const msgIds = tx.select({ id: schema.dmMessages.id })
.from(schema.attachments) .from(schema.dmMessages)
.where(inArray(schema.attachments.dmMessageId, msgIds)) .where(eq(schema.dmMessages.dmChannelId, dmId))
.all(); .all()
for (const att of dmAttachments) filesToDelete.push(att.filename); .map(m => m.id);
tx.delete(schema.attachments).where(inArray(schema.attachments.dmMessageId, msgIds)).run(); if (msgIds.length > 0) {
tx.delete(schema.dmReactions).where(inArray(schema.dmReactions.dmMessageId, msgIds)).run(); 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 // Tombstone user row — rename username to free it for reuse
+23
View File
@@ -706,6 +706,29 @@ export interface DeleteAccountRequest {
username: string; // Must match — confirmation safeguard 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<string, FederationIdentityDeleteResult>;
}
export interface FederationIdentityDeleteS2SRequest {
homeUserId: string;
homeInstance: string;
mode: 'soft' | 'full';
}
// ─── Storage Management Types ───────────────────────────────────────────── // ─── Storage Management Types ─────────────────────────────────────────────
export interface StorageBreakdown { export interface StorageBreakdown {
+7
View File
@@ -50,6 +50,8 @@ import type {
InvitePreview, InvitePreview,
GifResult, GifResult,
FederationRegistryEntry, FederationRegistryEntry,
FederationIdentityDeleteRequest,
FederationIdentityDeleteResponse,
} from '@backspace/shared'; } from '@backspace/shared';
export class RateLimitError extends Error { 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 }[] }>; 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 }>; getFederationRegistry: () => Promise<{ registry: FederationRegistryEntry[]; updatedAt: number }>;
putFederationRegistry: (data: { registry: FederationRegistryEntry[]; updatedAt: number }) => Promise<{ ok: boolean; updatedAt: number }>; putFederationRegistry: (data: { registry: FederationRegistryEntry[]; updatedAt: number }) => Promise<{ ok: boolean; updatedAt: number }>;
deleteFederationIdentity: (data: FederationIdentityDeleteRequest) => Promise<FederationIdentityDeleteResponse>;
}; };
readonly spaceLayout: { readonly spaceLayout: {
@@ -417,6 +420,10 @@ export class BackspaceApiClient {
request<{ ok: boolean; updatedAt: number }>( request<{ ok: boolean; updatedAt: number }>(
'PUT', '/users/@me/federation-registry', data 'PUT', '/users/@me/federation-registry', data
), ),
deleteFederationIdentity: (data: FederationIdentityDeleteRequest) =>
request<FederationIdentityDeleteResponse>(
'POST', '/users/@me/federation-identity/delete', data
),
}; };
this.spaceLayout = { this.spaceLayout = {
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom';
import type { InstanceInfoResponse, FederationRegistryEntry } from '@backspace/shared'; import type { InstanceInfoResponse, FederationRegistryEntry } from '@backspace/shared';
import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore'; import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { useUIStore } from '../../stores/uiStore';
import { isElectron } from '../../platform/platform'; import { isElectron } from '../../platform/platform';
import { ConfirmDialog } from '../ui/ConfirmDialog'; import { ConfirmDialog } from '../ui/ConfirmDialog';
@@ -410,7 +411,7 @@ function RegistryFilterBar({
// ─── DeleteIdentityDialog ─────────────────────────────────────────────────── // ─── DeleteIdentityDialog ───────────────────────────────────────────────────
type DeletionMode = 'leave' | 'nuke' | 'evaporate'; type DeletionMode = 'leave' | 'soft' | 'full';
type DeletionScope = 'this' | 'select' | 'all'; type DeletionScope = 'this' | 'select' | 'all';
function DeleteIdentityDialog({ function DeleteIdentityDialog({
@@ -423,19 +424,77 @@ function DeleteIdentityDialog({
onClose: () => void; onClose: () => void;
}) { }) {
const deleteIdentity = useInstanceStore((s) => s.deleteIdentity); const deleteIdentity = useInstanceStore((s) => s.deleteIdentity);
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 [loading, setLoading] = useState(false);
const handleConfirm = () => { const handleConfirm = async () => {
deleteIdentity(origin); // Resolve target origins based on scope
onClose(); 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( return ReactDOM.createPortal(
<div className="fixed inset-0 z-[10000] flex items-center justify-center animate-fade-in"> <div className="fixed inset-0 z-[10000] flex items-center justify-center animate-fade-in">
<div <div
className="absolute inset-0 bg-black/50" className="absolute inset-0 bg-black/50"
onClick={onClose} onClick={loading ? undefined : onClose}
/> />
<div className="relative max-w-md w-full mx-4 glass-modal rounded-xl p-6 animate-slide-up"> <div className="relative max-w-md w-full mx-4 glass-modal rounded-xl p-6 animate-slide-up">
<h3 className="text-base font-semibold text-txt-primary mb-1">Delete Identity</h3> <h3 className="text-base font-semibold text-txt-primary mb-1">Delete Identity</h3>
@@ -449,45 +508,54 @@ function DeleteIdentityDialog({
<button <button
type="button" type="button"
onClick={() => setMode('leave')} onClick={() => setMode('leave')}
disabled={loading}
className={`w-full text-left p-3 rounded-lg border transition-colors ${ className={`w-full text-left p-3 rounded-lg border transition-colors ${
mode === 'leave' mode === 'leave'
? 'bg-white/[0.03] border-white/[0.08]' ? 'bg-white/[0.03] border-white/[0.08]'
: 'bg-transparent border-white/[0.04] hover:border-white/[0.06]' : 'bg-transparent border-white/[0.04] hover:border-white/[0.06]'
}`} } disabled:opacity-50`}
> >
<div className="text-sm font-medium text-txt-primary">Leave quietly</div> <div className="text-sm font-medium text-txt-primary">Leave quietly</div>
<div className="text-[11px] text-txt-tertiary mt-0.5"> <div className="text-[11px] text-txt-tertiary mt-0.5">
Remove the connection. Your messages and data remain on the remote instance. Disconnect from this instance. Your account and all data remain.
</div> </div>
</button> </button>
{/* Nuke everything */} {/* Delete User (soft) */}
<button <button
type="button" type="button"
onClick={() => setMode('nuke')} onClick={() => setMode('soft')}
disabled={loading}
className={`w-full text-left p-3 rounded-lg border transition-colors ${ className={`w-full text-left p-3 rounded-lg border transition-colors ${
mode === 'nuke' mode === 'soft'
? 'bg-white/[0.03] border-white/[0.08]'
: 'bg-transparent border-white/[0.04] hover:border-white/[0.06]'
} disabled:opacity-50`}
>
<div className="text-sm font-medium text-txt-primary">Delete User</div>
<div className="text-[11px] text-txt-tertiary mt-0.5">
Delete your account. Your messages stay visible as &lsquo;Deleted User&rsquo;.
</div>
</button>
{/* Nuke everything (full) */}
<button
type="button"
onClick={() => setMode('full')}
disabled={loading}
className={`w-full text-left p-3 rounded-lg border transition-colors ${
mode === 'full'
? 'bg-accent-rose/[0.04] border-accent-rose/20' ? 'bg-accent-rose/[0.04] border-accent-rose/20'
: 'bg-transparent border-white/[0.04] hover:border-white/[0.06]' : 'bg-transparent border-white/[0.04] hover:border-white/[0.06]'
}`} } disabled:opacity-50`}
> >
<div className={`text-sm font-medium ${mode === 'nuke' ? 'text-txt-danger' : 'text-txt-primary'}`}> <div className={`text-sm font-medium ${mode === 'full' ? 'text-txt-danger' : 'text-txt-primary'}`}>
Nuke everything Nuke everything
</div> </div>
<div className="text-[11px] text-txt-tertiary mt-0.5"> <div className="text-[11px] text-txt-tertiary mt-0.5">
Delete your account and all associated data on the remote instance. Delete your account and purge all private data (DMs, reactions). Space messages remain as &lsquo;Deleted User&rsquo;.
</div> </div>
</button> </button>
{/* Evaporate (coming soon) */}
<div
className="w-full text-left p-3 rounded-lg border border-dashed border-white/[0.04] opacity-40 cursor-not-allowed"
>
<div className="text-sm font-medium text-txt-primary">Evaporate</div>
<div className="text-[11px] text-txt-tertiary mt-0.5">
Gradually fade your presence coming soon.
</div>
</div>
</div> </div>
{/* Scope selector */} {/* Scope selector */}
@@ -495,18 +563,22 @@ function DeleteIdentityDialog({
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Scope</div> <div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Scope</div>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
{([ {([
{ key: 'this' as DeletionScope, label: 'This instance only' }, { key: 'this' as DeletionScope, label: 'This instance only', disabled: false },
{ key: 'select' as DeletionScope, label: 'Select instances...' }, { key: 'select' as DeletionScope, label: 'Select instances...', disabled: true },
{ key: 'all' as DeletionScope, label: 'All remote instances' }, { key: 'all' as DeletionScope, label: 'All remote instances', disabled: false },
]).map((opt) => ( ]).map((opt) => (
<button <button
key={opt.key} key={opt.key}
type="button" type="button"
onClick={() => setScope(opt.key)} onClick={() => !opt.disabled && setScope(opt.key)}
disabled={opt.disabled || loading}
title={opt.disabled ? 'Coming soon' : undefined}
className={`flex-1 px-2 py-1.5 text-[11px] font-medium rounded transition-colors ${ className={`flex-1 px-2 py-1.5 text-[11px] font-medium rounded transition-colors ${
scope === opt.key opt.disabled
? 'bg-accent-lavender/15 text-accent-lavender' ? 'bg-white/[0.02] text-txt-tertiary/40 cursor-not-allowed'
: 'bg-white/[0.04] text-txt-tertiary hover:text-txt-secondary' : scope === opt.key
? 'bg-accent-lavender/15 text-accent-lavender'
: 'bg-white/[0.04] text-txt-tertiary hover:text-txt-secondary'
}`} }`}
> >
{opt.label} {opt.label}
@@ -519,15 +591,17 @@ function DeleteIdentityDialog({
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={onClose} onClick={onClose}
className="flex-1 py-2.5 text-sm font-medium text-txt-secondary bg-interactive-hover hover:bg-interactive-selected rounded-lg transition-colors" disabled={loading}
className="flex-1 py-2.5 text-sm font-medium text-txt-secondary bg-interactive-hover hover:bg-interactive-selected rounded-lg transition-colors disabled:opacity-50"
> >
Cancel Cancel
</button> </button>
<button <button
onClick={handleConfirm} onClick={handleConfirm}
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={loading}
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"
> >
Delete Identity {loading ? 'Deleting...' : mode === 'leave' ? 'Disconnect' : 'Delete Identity'}
</button> </button>
</div> </div>
</div> </div>
+26 -3
View File
@@ -157,7 +157,7 @@ interface InstanceState {
registry: Map<string, FederationRegistryEntry>; registry: Map<string, FederationRegistryEntry>;
registryUpdatedAt: number; registryUpdatedAt: number;
syncRegistry: () => Promise<void>; syncRegistry: () => Promise<void>;
deleteIdentity: (origin: string) => void; deleteIdentity: (origins: string[], mode?: 'leave' | 'soft' | 'full') => Promise<Record<string, { success: boolean; error?: string; ownedSpaces?: { id: string; name: string }[] }>>;
forceRemoveEntry: (origin: string) => void; forceRemoveEntry: (origin: string) => void;
probeInstance: (url: string) => Promise<InstanceInfoResponse & { origin: string }>; probeInstance: (url: string) => Promise<InstanceInfoResponse & { origin: string }>;
@@ -703,8 +703,31 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
await Promise.all([homePromise, ...remotePromises]); await Promise.all([homePromise, ...remotePromises]);
}, },
deleteIdentity: (_origin: string) => { deleteIdentity: async (origins: string[], mode: 'leave' | 'soft' | 'full' = 'leave') => {
useUIStore.getState().addToast('Identity deletion is not yet implemented', 'info', 3000); // 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) => { forceRemoveEntry: (origin: string) => {