fix(dm): keep 1-on-1 dm_members on tombstone, drop only group membership (S1)

This commit is contained in:
Jannis Braun
2026-07-02 15:32:04 +02:00
parent 411e9baaa9
commit e5a1cc9506
4 changed files with 92 additions and 10 deletions
+3 -3
View File
@@ -403,7 +403,7 @@ All cleanup runs in a single SQLite transaction:
- `memberRoles` -- removes all role assignments
- `friends` -- removes all friendships (both directions)
- `friendRequests` -- removes all friend requests (both directions)
- `dmMembers` -- removes from all DM channels
- `dmMembers` -- **partitioned**: the row is KEPT for 1-on-1 DMs (`dm_channels.ownerId IS NULL`) so the thread survives as a readable anonymized "Deleted User" thread; it is deleted only for group DMs (`ownerId IS NOT NULL`). The in-function `userDmChannelIds` local captures the user's DM channel ids before partitioning.
- `readStates` -- removes all read state records
- `reactions` -- removes all message reactions
- `dmReactions` -- removes all DM reactions
@@ -460,8 +460,8 @@ interface TombstoneOptions { purgeContent?: boolean }
function tombstoneUser(uid: string, options?: TombstoneOptions): string[]
```
- **`purgeContent: true`** (default / omitted): full tombstone — removes the user from spaces, friends, DM membership, and read-states; then also deletes `reactions`, `dmReactions`, and the user's space `messages` with their attachments and embeds.
- **`purgeContent: false`**: soft tombstone — removes the user from spaces, friends, DM membership (`dm_members`), and read-states. The `purgeContent: false` flag skips only `reactions`, `dm_reactions`, and the user's space `messages` (with attachments + embeds); DM membership cleanup and orphaned-DM purge always run in both modes (per `userDeletion.ts:121-126, 169-202`) because zero-member DM channels are unreachable garbage regardless of authorship retention. Used by the federation identity soft-delete endpoint so remote message history is retained.
- **`purgeContent: true`** (default / omitted): full tombstone — removes the user from spaces, friends, group DM membership, and read-states; then also deletes `reactions`, `dmReactions`, and the user's space `messages` with their attachments and embeds. 1-on-1 DM membership is kept (see below).
- **`purgeContent: false`**: soft tombstone — removes the user from spaces, friends, group DM membership, and read-states. The `purgeContent: false` flag skips only `reactions`, `dm_reactions`, and the user's space `messages` (with attachments + embeds). The DM membership partition and orphaned-DM purge always run in both modes: group-DM `dm_members` rows are deleted, 1-on-1 `dm_members` rows are KEPT (so the thread survives as an anonymized "Deleted User" thread), and any resulting zero-member DM channel is purged as unreachable garbage. Used by the federation identity soft-delete endpoint so remote message history is retained.
### `resolveOrCreateReplicatedUser` and Deleted Users
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { eq } from 'drizzle-orm';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
vi.mock('../db/index.js', () => ({ getDb: () => testDb, getRawDb: () => sqlite, schema }));
function applyMigrations(db: Database.Database): void {
const dir = path.resolve(__dirname, '../../drizzle');
for (const f of fs.readdirSync(dir).filter(x => x.endsWith('.sql')).sort()) {
for (const stmt of fs.readFileSync(path.join(dir, f), 'utf8').split(/-->\s*statement-breakpoint/)) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedUser(id: string, extra: Partial<typeof schema.users.$inferInsert> = {}) {
testDb.insert(schema.users).values({ id, username: id, passwordHash: 'x', createdAt: Date.now(), ...extra }).run();
}
function seedDm(id: string, ownerId: string | null) {
testDb.insert(schema.dmChannels).values({ id, ownerId, createdAt: Date.now() }).run();
}
function seedMember(dmChannelId: string, userId: string) {
testDb.insert(schema.dmMembers).values({ dmChannelId, userId, closed: 0 }).run();
}
describe('tombstoneUser — DM membership partition', () => {
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
vi.clearAllMocks();
});
afterEach(() => sqlite.close());
it('keeps the deleted user 1-on-1 membership, removes group membership', async () => {
const { tombstoneUser } = await import('./userDeletion.js');
seedUser('victim'); seedUser('survivor'); seedUser('groupmate');
seedDm('dm_1on1', null); seedMember('dm_1on1', 'victim'); seedMember('dm_1on1', 'survivor');
seedDm('dm_group', 'survivor'); seedMember('dm_group', 'victim'); seedMember('dm_group', 'survivor'); seedMember('dm_group', 'groupmate');
tombstoneUser('victim', { purgeContent: false });
const memberships = testDb.select().from(schema.dmMembers).where(eq(schema.dmMembers.userId, 'victim')).all();
expect(memberships.map(m => m.dmChannelId)).toEqual(['dm_1on1']); // 1-on-1 kept, group removed
// Survivor's 1-on-1 channel still exists and still has the survivor
expect(testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, 'dm_1on1')).get()).toBeTruthy();
});
});
+21 -2
View File
@@ -1,5 +1,5 @@
import crypto from 'crypto';
import { eq, or, and, inArray } from 'drizzle-orm';
import { eq, or, and, inArray, isNotNull } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
export interface DeletionBroadcastTargets {
@@ -122,7 +122,26 @@ export function tombstoneUser(uid: string, options?: TombstoneOptions): string[]
tx.delete(schema.memberRoles).where(eq(schema.memberRoles.userId, uid)).run();
tx.delete(schema.friends).where(or(eq(schema.friends.userId, uid), eq(schema.friends.friendId, 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();
// DM membership: keep the row for 1-on-1 DMs (ownerId NULL) so the thread
// survives as a readable "Deleted User" thread; drop it for group DMs.
const userDmChannelIds = tx.select({ dmChannelId: schema.dmMembers.dmChannelId })
.from(schema.dmMembers)
.where(eq(schema.dmMembers.userId, uid))
.all()
.map(r => r.dmChannelId);
if (userDmChannelIds.length > 0) {
const groupDmChannelIds = tx.select({ id: schema.dmChannels.id })
.from(schema.dmChannels)
.where(and(inArray(schema.dmChannels.id, userDmChannelIds), isNotNull(schema.dmChannels.ownerId)))
.all()
.map(c => c.id);
if (groupDmChannelIds.length > 0) {
tx.delete(schema.dmMembers).where(and(
eq(schema.dmMembers.userId, uid),
inArray(schema.dmMembers.dmChannelId, groupDmChannelIds),
)).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();
@@ -317,7 +317,7 @@ describe('Federation identity deletion — server suite', () => {
homeInspect.close();
});
it('#3 soft mode: tombstone shape, messages/reactions retained, dm membership cleared', async () => {
it('#3 soft mode: tombstone shape, messages/reactions retained, 1-on-1 dm membership kept', async () => {
const fx = await setupFullDeletionFixture('t3');
const { openInspector } = await import('./helpers/dbInspect.js');
@@ -358,7 +358,8 @@ describe('Federation identity deletion — server suite', () => {
expect(remote.spaceMembersForUser(fx.remoteUser.id)).toEqual([]);
expect(remote.messagesAuthored(fx.remoteUser.id).length).toBe(2); // RETAINED
expect(remote.reactionsForUser(fx.remoteUser.id).length).toBe(2); // RETAINED
expect(remote.dmMembership(fx.remoteUser.id)).toEqual([]); // dm_members always cleared
// 1-on-1 DM membership is KEPT (anonymized) so the thread survives as "Deleted User"
expect(remote.dmMembership(fx.remoteUser.id).map(r => r.dmChannelId)).toEqual([fx.dmChannelId]);
expect(remote.dmChannelExists(fx.dmChannelId)).toBe(true); // other party still member
remote.close();
@@ -394,7 +395,8 @@ describe('Federation identity deletion — server suite', () => {
expect(remote.messagesAuthored(fx.remoteUser.id).length).toBe(0); // PURGED
expect(remote.reactionsForUser(fx.remoteUser.id).length).toBe(0); // PURGED
expect(remote.dmMembership(fx.remoteUser.id)).toEqual([]);
// 1-on-1 DM membership is KEPT (anonymized) so the thread survives as "Deleted User"
expect(remote.dmMembership(fx.remoteUser.id).map(r => r.dmChannelId)).toEqual([fx.dmChannelId]);
// 1-on-1 DM with another live participant SURVIVES (other party still member)
expect(remote.dmChannelExists(fx.dmChannelId)).toBe(true);
remote.close();
@@ -897,7 +899,8 @@ describe('Federation identity deletion — all-remotes fan-out', () => {
expect(ins.spaceMembersForUser(fixtures[i].remoteUser.id)).toEqual([]);
expect(ins.messagesAuthored(fixtures[i].remoteUser.id).length).toBe(2); // RETAINED
expect(ins.reactionsForUser(fixtures[i].remoteUser.id).length).toBe(2); // RETAINED
expect(ins.dmMembership(fixtures[i].remoteUser.id)).toEqual([]);
// 1-on-1 DM membership is KEPT (anonymized) so the thread survives as "Deleted User"
expect(ins.dmMembership(fixtures[i].remoteUser.id).map(r => r.dmChannelId)).toEqual([fixtures[i].dmChannelId]);
expect(ins.dmChannelExists(fixtures[i].dmChannelId)).toBe(true);
ins.close();
});
@@ -958,7 +961,8 @@ describe('Federation identity deletion — all-remotes fan-out', () => {
expect(after!.username).toBe(`!deleted:${fixtures[i].remoteUser.id}`);
expect(ins.messagesAuthored(fixtures[i].remoteUser.id).length).toBe(0); // PURGED
expect(ins.reactionsForUser(fixtures[i].remoteUser.id).length).toBe(0); // PURGED
expect(ins.dmMembership(fixtures[i].remoteUser.id)).toEqual([]);
// 1-on-1 DM membership is KEPT (anonymized) so the thread survives as "Deleted User"
expect(ins.dmMembership(fixtures[i].remoteUser.id).map(r => r.dmChannelId)).toEqual([fixtures[i].dmChannelId]);
// Observer remains a member, so DM channel survives.
expect(ins.dmChannelExists(fixtures[i].dmChannelId)).toBe(true);
ins.close();