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:
@@ -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": { "<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
|
||||
|
||||
When `isDeleted === 1`, returns an anonymized profile:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "<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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -384,7 +384,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
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<void> {
|
||||
|
||||
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<void> {
|
||||
|
||||
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();
|
||||
|
||||
@@ -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<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 ────────────────────────────────────────────
|
||||
// Server-to-server: receive relayed DM events from a peer instance.
|
||||
// Authenticated via HMAC-SHA256 signature, NOT JWT.
|
||||
@@ -1397,12 +1497,23 @@ export function resolveOrCreateReplicatedUser(
|
||||
homeInstance: string,
|
||||
db: ReturnType<typeof getDb>,
|
||||
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);
|
||||
|
||||
// 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 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
|
||||
// domain so the username is globally unique and human-readable.
|
||||
@@ -1612,6 +1723,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 +2288,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 +2306,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 +2396,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 +2636,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 +2778,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 +2888,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 +3027,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 +3314,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 = {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { eq, or, and, inArray } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate, verifyPassword, hashPassword, signJwt } from '../utils/auth.js';
|
||||
import { connectionManager } from '../ws/handler.js';
|
||||
import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance, SpaceLayoutItem, SpaceFolder, Activity } from '@backspace/shared';
|
||||
import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance, SpaceLayoutItem, SpaceFolder, Activity, FederationIdentityDeleteRequest, FederationIdentityDeleteResponse, FederationIdentityDeleteResult } from '@backspace/shared';
|
||||
import { AVATAR_COLORS } from '@backspace/shared';
|
||||
import { sanitizeUser } from '../utils/sanitize.js';
|
||||
import { deleteUploadFile, deleteAttachmentByFilename } from '../utils/fileCleanup.js';
|
||||
@@ -11,6 +11,8 @@ import { tombstoneUser } from '../utils/userDeletion.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
import { resizeProfileImage } from '../utils/thumbnail.js';
|
||||
import { config } from '../config.js';
|
||||
import { buildFederationHeaders, getOurOrigin } from '../utils/federationAuth.js';
|
||||
import { extractDomain } from './federation.js';
|
||||
import path from 'path';
|
||||
|
||||
/** Validates that a URL is a safe asset URL (relative upload path, bare filename, or http/https) */
|
||||
@@ -579,6 +581,99 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(200).send({ ok: true, updatedAt });
|
||||
});
|
||||
|
||||
// POST /api/users/@me/federation-identity/delete — request identity deletion on remote instances via S2S
|
||||
app.post<{ Body: FederationIdentityDeleteRequest }>('/api/users/@me/federation-identity/delete', {
|
||||
preHandler: authenticate,
|
||||
config: { rateLimit: { max: 5, timeWindow: '15 minutes' } },
|
||||
}, async (request, reply) => {
|
||||
const { origins, mode } = request.body;
|
||||
|
||||
if (!mode || !['soft', 'full'].includes(mode)) {
|
||||
return reply.code(400).send({ error: 'Invalid mode: must be "soft" or "full"', statusCode: 400 });
|
||||
}
|
||||
if (!Array.isArray(origins) || origins.length === 0 || !origins.every(o => typeof o === 'string')) {
|
||||
return reply.code(400).send({ error: 'origins must be a non-empty array of strings', statusCode: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const ourOrigin = getOurOrigin();
|
||||
const homeInstance = extractDomain(ourOrigin);
|
||||
|
||||
const results: Record<string, FederationIdentityDeleteResult> = {};
|
||||
|
||||
await Promise.all(origins.map(async (origin) => {
|
||||
try {
|
||||
// Look up peer
|
||||
const peer = db
|
||||
.select()
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.origin, origin))
|
||||
.get();
|
||||
|
||||
if (!peer || peer.status !== 'active') {
|
||||
results[origin] = { success: false, error: 'no_active_peer' };
|
||||
return;
|
||||
}
|
||||
|
||||
// Build HMAC-signed request
|
||||
const body = JSON.stringify({
|
||||
homeUserId: request.userId,
|
||||
homeInstance,
|
||||
mode,
|
||||
});
|
||||
|
||||
const headers = buildFederationHeaders(body, peer.hmacSecret, ourOrigin);
|
||||
|
||||
// Send to remote with 15s timeout
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15_000);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${origin}/api/federation/identity`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
const data = await response.json() as Record<string, unknown>;
|
||||
|
||||
if (response.ok) {
|
||||
results[origin] = { success: true };
|
||||
} else if (data.error === 'owns_spaces') {
|
||||
results[origin] = {
|
||||
success: false,
|
||||
error: 'owns_spaces',
|
||||
ownedSpaces: data.ownedSpaces as { id: string; name: string }[],
|
||||
};
|
||||
} else {
|
||||
results[origin] = {
|
||||
success: false,
|
||||
error: (data.error as string) || `HTTP ${response.status}`,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
results[origin] = { success: false, error: 'timeout' };
|
||||
} else {
|
||||
results[origin] = { success: false, error: 'unreachable' };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
results[origin] = {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
const response: FederationIdentityDeleteResponse = { results };
|
||||
return reply.code(200).send(response);
|
||||
});
|
||||
|
||||
// PUT /api/users/@me/space-layout — save sidebar layout (reorder, folders)
|
||||
app.put<{ Body: { items: SpaceLayoutItem[]; folders: Record<string, { name: string | null; color: string | null; spaceIds: string[] }>; updatedAt?: number } }>(
|
||||
'/api/users/@me/space-layout', { preHandler: authenticate }, async (request, reply) => {
|
||||
|
||||
@@ -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();
|
||||
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,6 +85,7 @@ export function tombstoneUser(uid: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
if (purge) {
|
||||
// Clean up orphaned DM channels (zero members after our removal)
|
||||
const orphanedDmIds = tx.select({ id: schema.dmChannels.id })
|
||||
.from(schema.dmChannels)
|
||||
@@ -109,6 +119,7 @@ export function tombstoneUser(uid: string): string[] {
|
||||
}
|
||||
tx.delete(schema.dmChannels).where(eq(schema.dmChannels.id, dmId)).run();
|
||||
}
|
||||
}
|
||||
|
||||
// Tombstone user row — rename username to free it for reuse
|
||||
tx.update(schema.users).set({
|
||||
|
||||
@@ -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<string, FederationIdentityDeleteResult>;
|
||||
}
|
||||
|
||||
export interface FederationIdentityDeleteS2SRequest {
|
||||
homeUserId: string;
|
||||
homeInstance: string;
|
||||
mode: 'soft' | 'full';
|
||||
}
|
||||
|
||||
// ─── Storage Management Types ─────────────────────────────────────────────
|
||||
|
||||
export interface StorageBreakdown {
|
||||
|
||||
@@ -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<FederationIdentityDeleteResponse>;
|
||||
};
|
||||
|
||||
readonly spaceLayout: {
|
||||
@@ -417,6 +420,10 @@ export class BackspaceApiClient {
|
||||
request<{ ok: boolean; updatedAt: number }>(
|
||||
'PUT', '/users/@me/federation-registry', data
|
||||
),
|
||||
deleteFederationIdentity: (data: FederationIdentityDeleteRequest) =>
|
||||
request<FederationIdentityDeleteResponse>(
|
||||
'POST', '/users/@me/federation-identity/delete', data
|
||||
),
|
||||
};
|
||||
|
||||
this.spaceLayout = {
|
||||
|
||||
@@ -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<DeletionMode>('leave');
|
||||
const [scope, setScope] = useState<DeletionScope>('this');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleConfirm = () => {
|
||||
deleteIdentity(origin);
|
||||
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(
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center animate-fade-in">
|
||||
<div
|
||||
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">
|
||||
<h3 className="text-base font-semibold text-txt-primary mb-1">Delete Identity</h3>
|
||||
@@ -449,45 +508,54 @@ function DeleteIdentityDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('leave')}
|
||||
disabled={loading}
|
||||
className={`w-full text-left p-3 rounded-lg border transition-colors ${
|
||||
mode === 'leave'
|
||||
? '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">Leave quietly</div>
|
||||
<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>
|
||||
</button>
|
||||
|
||||
{/* Nuke everything */}
|
||||
{/* Delete User (soft) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('nuke')}
|
||||
onClick={() => setMode('soft')}
|
||||
disabled={loading}
|
||||
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 ‘Deleted User’.
|
||||
</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-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
|
||||
</div>
|
||||
<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 ‘Deleted User’.
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* Scope selector */}
|
||||
@@ -495,16 +563,20 @@ function DeleteIdentityDialog({
|
||||
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Scope</div>
|
||||
<div className="flex gap-1.5">
|
||||
{([
|
||||
{ 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) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
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 ${
|
||||
scope === opt.key
|
||||
opt.disabled
|
||||
? 'bg-white/[0.02] text-txt-tertiary/40 cursor-not-allowed'
|
||||
: scope === opt.key
|
||||
? 'bg-accent-lavender/15 text-accent-lavender'
|
||||
: 'bg-white/[0.04] text-txt-tertiary hover:text-txt-secondary'
|
||||
}`}
|
||||
@@ -519,15 +591,17 @@ function DeleteIdentityDialog({
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -157,7 +157,7 @@ interface InstanceState {
|
||||
registry: Map<string, FederationRegistryEntry>;
|
||||
registryUpdatedAt: number;
|
||||
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;
|
||||
|
||||
probeInstance: (url: string) => Promise<InstanceInfoResponse & { origin: string }>;
|
||||
@@ -703,8 +703,31 @@ export const useInstanceStore = create<InstanceState>((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) => {
|
||||
|
||||
Reference in New Issue
Block a user