diff --git a/packages/server/src/routes/federation.resolveOrCreate.test.ts b/packages/server/src/routes/federation.resolveOrCreate.test.ts index fe83c714..982bc23c 100644 --- a/packages/server/src/routes/federation.resolveOrCreate.test.ts +++ b/packages/server/src/routes/federation.resolveOrCreate.test.ts @@ -131,4 +131,21 @@ describe('resolveOrCreateReplicatedUser — self-homed identity guard', () => { expect(result).not.toBeNull(); expect(result!.username).toBe('bob@orbit.ddns.net'); }); + + it('refuses stub creation when the wire snapshot marks the identity deleted', async () => { + const { resolveOrCreateReplicatedUser } = await import('./federation.js'); + const result = resolveOrCreateReplicatedUser('remote-del', 'orbit.ddns.net', testDb, { username: null, deleted: true }); + expect(result).toBeNull(); + expect(testDb.select().from(schema.users).all()).toHaveLength(0); + }); + + it('a deleted-marked identity that already resolves locally still returns the existing row', async () => { + testDb.insert(schema.users).values({ + id: 'stub-1', username: 'old@orbit.ddns.net', passwordHash: '!federation-replicated', + homeInstance: 'orbit.ddns.net', homeUserId: 'remote-del', createdAt: 1, + }).run(); + const { resolveOrCreateReplicatedUser } = await import('./federation.js'); + const result = resolveOrCreateReplicatedUser('remote-del', 'orbit.ddns.net', testDb, { deleted: true }); + expect(result?.id).toBe('stub-1'); // historical attribution stays intact + }); }); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index ab1d905f..40c51c5c 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -3661,11 +3661,19 @@ export function resolveOrCreateReplicatedUser( homeUserId: string, homeInstance: string, db: ReturnType, - hints?: { username?: string | null; status?: 'online' | 'idle' | 'dnd' | 'offline' | null }, + hints?: { username?: string | null; status?: 'online' | 'idle' | 'dnd' | 'offline' | null; deleted?: boolean | null }, ): typeof schema.users.$inferSelect | null { const existing = findFederatedUser(homeUserId, homeInstance, db, hints); if (existing) return backfillHomeUserId(existing, homeUserId, db); + // A participant the sender marks as deleted must not materialize as a new + // stub — mirror of the local-tombstone skip below. An existing row still + // resolves above, so historical attribution is unaffected (spec §3.3). + if (hints?.deleted) { + console.log(`[federation] Skipping stub creation for remotely-deleted identity homeUserId=${homeUserId}`); + return null; + } + // 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. @@ -3916,7 +3924,7 @@ async function processCreateEvent( }> = []; for (const p of event.participants) { - let localUser = resolveOrCreateReplicatedUser(p.homeUserId, p.homeInstance, db, { username: p.profile?.username, status: p.profile?.status }); + let localUser = resolveOrCreateReplicatedUser(p.homeUserId, p.homeInstance, db, { username: p.profile?.username, status: p.profile?.status, deleted: p.profile?.deleted }); // 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.) @@ -4513,7 +4521,7 @@ export async function processMemberAddEvent( // Resolve owner — create a replicated stub if unknown 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, status: event.group.owner.profile?.status }); + const ownerLocal = resolveOrCreateReplicatedUser(event.group.owner.homeUserId, event.group.owner.homeInstance, db, { username: event.group.owner.profile?.username, status: event.group.owner.profile?.status, deleted: event.group.owner.profile?.deleted }); ownerId = ownerLocal?.id ?? null; } @@ -4550,7 +4558,7 @@ export async function processMemberAddEvent( // Add all roster members — create replicated user stubs for any // participants from remote instances that haven't been seen before. for (const member of event.group.members) { - const rosterUser = resolveOrCreateReplicatedUser(member.homeUserId, member.homeInstance, db, { username: member.profile?.username, status: member.profile?.status }); + const rosterUser = resolveOrCreateReplicatedUser(member.homeUserId, member.homeInstance, db, { username: member.profile?.username, status: member.profile?.status, deleted: member.profile?.deleted }); // Skip deleted identities — tombstoned users can't be added to a DM if (!rosterUser) continue; const existing = db.select().from(schema.dmMembers) @@ -4604,7 +4612,7 @@ export async function processMemberAddEvent( event.membership.user.homeUserId, event.membership.user.homeInstance, db, - { username: event.membership.user.profile?.username, status: event.membership.user.profile?.status }, + { username: event.membership.user.profile?.username, status: event.membership.user.profile?.status, deleted: event.membership.user.profile?.deleted }, ); if (!localUser) { // The user's identity has been deleted — don't add a tombstoned user to the DM @@ -4643,7 +4651,7 @@ export async function processMemberAddEvent( // would otherwise find the channel already present and fall through to the incremental path, // creating spurious system messages (the exact bug this fixes). const actorUser = event.membership.addedBy - ? resolveOrCreateReplicatedUser(event.membership.addedBy.homeUserId, event.membership.addedBy.homeInstance, db, { username: event.membership.addedBy.profile?.username, status: event.membership.addedBy.profile?.status }) + ? resolveOrCreateReplicatedUser(event.membership.addedBy.homeUserId, event.membership.addedBy.homeInstance, db, { username: event.membership.addedBy.profile?.username, status: event.membership.addedBy.profile?.status, deleted: event.membership.addedBy.profile?.deleted }) : null; const actorId = actorUser?.id ?? localUser.id; const addBaseName = localUser.username?.includes('@') ? localUser.username.split('@')[0] : (localUser.username ?? 'Unknown'); @@ -4952,7 +4960,7 @@ export function processOwnershipTransferEvent( event.ownership.newOwner.homeUserId, event.ownership.newOwner.homeInstance, db, - { username: event.ownership.newOwner.profile?.username, status: event.ownership.newOwner.profile?.status }, + { username: event.ownership.newOwner.profile?.username, status: event.ownership.newOwner.profile?.status, deleted: event.ownership.newOwner.profile?.deleted }, ); if (!newOwnerLocal) { rejected.push({ messageId: event.messageId, reason: 'participant_not_found' }); @@ -5127,7 +5135,7 @@ async function processFriendRequestCreateEvent( } // Resolve the sender (create stub if needed — they're on a remote instance) - const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username, status: event.friendship.fromProfile?.status }); + const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username, status: event.friendship.fromProfile?.status, deleted: event.friendship.fromProfile?.deleted }); if (!fromUserResolved) { // Sender's identity has been deleted — silently accept to drop the event accepted.push(event.messageId); @@ -5245,7 +5253,7 @@ 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, status: event.friendship.toProfile?.status }); + const toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username, status: event.friendship.toProfile?.status, deleted: event.friendship.toProfile?.deleted }); if (!toUser) { // Recipient's identity has been deleted — accept idempotently to drop the event accepted.push(event.messageId); @@ -5385,14 +5393,14 @@ async function processFriendAddEvent( } // Resolve both users (create stubs if needed) and hydrate with profile data - const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username, status: event.friendship.fromProfile?.status }); + const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username, status: event.friendship.fromProfile?.status, deleted: event.friendship.fromProfile?.deleted }); if (!fromUserResolved) { // One party's identity is deleted — accept idempotently to drop the event accepted.push(event.messageId); return; } let fromUser = await hydrateReplicatedUserProfile(fromUserResolved, event.friendship.fromProfile, db); - const toUserResolved = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username, status: event.friendship.toProfile?.status }); + const toUserResolved = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username, status: event.friendship.toProfile?.status, deleted: event.friendship.toProfile?.deleted }); if (!toUserResolved) { accepted.push(event.messageId); return; @@ -6479,7 +6487,7 @@ export async function processGroupMetadataUpdateEvent( actorParticipant.homeUserId, actorParticipant.homeInstance, db, - { username: actorParticipant.profile?.username, status: actorParticipant.profile?.status }, + { username: actorParticipant.profile?.username, status: actorParticipant.profile?.status, deleted: actorParticipant.profile?.deleted }, ); actorUserId = actorUser?.id ?? null; } diff --git a/packages/server/src/routes/social.snapshot.test.ts b/packages/server/src/routes/social.snapshot.test.ts new file mode 100644 index 00000000..bdc5e034 --- /dev/null +++ b/packages/server/src/routes/social.snapshot.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach, 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'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +let _sf = 1; +vi.mock('../utils/snowflake.js', () => ({ + generateSnowflake: () => String(_sf++), + setWorkerId: vi.fn(), +})); + +vi.mock('../utils/federationAuth.js', async (importActual) => { + const actual = await importActual(); + return { ...actual, getOurOrigin: () => 'https://home.test' }; +}); + +// social.ts imports connectionManager from ws/handler.js — stub the minimal +// surface so the route module loads at test time. buildProfileSnapshot doesn't +// touch any of these. +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToUser: vi.fn(), + sendToSpace: vi.fn(), + sendToDmMembers: vi.fn(), + sendToAdmins: vi.fn(), + getAllOnlineUserIds: () => [], + evictFederatedCallsForHost: vi.fn(), + federatedCalls: new Map(), + isUserOnline: vi.fn(), + lateBindFederatedCall: vi.fn(), + }, +})); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sql = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + for (const stmt of sql.split(/-->\s*statement-breakpoint/)) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + _sf = 1; +}); + +describe('buildProfileSnapshot — deleted users', () => { + it('never ships the !deleted: tombstone marker', async () => { + const { buildProfileSnapshot } = await import('./social.js'); + const row = { + username: '!deleted:12345', displayName: null, avatar: null, avatarColor: null, + banner: null, bio: null, status: 'offline', homeInstance: null, isDeleted: 1, + } as unknown as Parameters[0]; + const snap = buildProfileSnapshot(row); + expect(snap.deleted).toBe(true); + expect(snap.username ?? null).toBeNull(); + }); +}); diff --git a/packages/server/src/routes/social.ts b/packages/server/src/routes/social.ts index e76ed08d..0c0b6551 100644 --- a/packages/server/src/routes/social.ts +++ b/packages/server/src/routes/social.ts @@ -20,7 +20,11 @@ import type { } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; -function buildProfileSnapshot(user: typeof schema.users.$inferSelect): FederationRelayProfileSnapshot { +export function buildProfileSnapshot(user: typeof schema.users.$inferSelect): FederationRelayProfileSnapshot { + if (user.isDeleted) { + // Never ship the internal '!deleted:' tombstone marker (spec §3.3). + return { deleted: true }; + } // Only meaningful for native users (us). Replicated stubs carry stale status // their home owns — emitting it would flap remote UIs on relay receipt. const status = !user.homeInstance && user.status diff --git a/packages/server/src/utils/federationOutbox.participants.test.ts b/packages/server/src/utils/federationOutbox.participants.test.ts new file mode 100644 index 00000000..8e9b798a --- /dev/null +++ b/packages/server/src/utils/federationOutbox.participants.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, 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'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +let _sf = 1; +vi.mock('./snowflake.js', () => ({ + generateSnowflake: () => String(_sf++), + setWorkerId: vi.fn(), +})); + +vi.mock('./federationAuth.js', async (importActual) => { + const actual = await importActual(); + return { ...actual, getOurOrigin: () => 'https://home.test' }; +}); + +// federationOutbox.ts imports extractDomain from routes/federation.js, which in +// turn imports connectionManager/ws — stub the minimal surface so the module +// graph loads at test time. getDmParticipants doesn't touch any of these. +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToUser: vi.fn(), + sendToSpace: vi.fn(), + sendToDmMembers: vi.fn(), + sendToAdmins: vi.fn(), + getAllOnlineUserIds: () => [], + evictFederatedCallsForHost: vi.fn(), + federatedCalls: new Map(), + isUserOnline: vi.fn(), + lateBindFederatedCall: vi.fn(), + }, +})); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sql = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + for (const stmt of sql.split(/-->\s*statement-breakpoint/)) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + _sf = 1; +}); + +describe('getDmParticipants — deleted members', () => { + it('ships deleted:true and NO username for tombstoned members', async () => { + testDb.insert(schema.users).values([ + { id: 'alice', username: 'alice', passwordHash: 'h', homeInstance: null, createdAt: 1 }, + { id: 'ghost', username: '!deleted:ghost', passwordHash: 'h', homeInstance: null, isDeleted: 1, createdAt: 1 }, + ]).run(); + testDb.insert(schema.dmChannels).values({ id: 'ch1', federatedId: 'fed-ch1', createdAt: 1 }).run(); + testDb.insert(schema.dmMembers).values([ + { dmChannelId: 'ch1', userId: 'alice', closed: 0 }, + { dmChannelId: 'ch1', userId: 'ghost', closed: 0 }, + ]).run(); + + const { getDmParticipants } = await import('./federationOutbox.js'); + const participants = getDmParticipants('ch1'); + const ghost = participants.find(p => p.homeUserId === 'ghost')!; + expect(ghost.profile?.deleted).toBe(true); + expect(ghost.profile?.username ?? null).toBeNull(); + expect(ghost.profile?.displayName ?? null).toBeNull(); + const alice = participants.find(p => p.homeUserId === 'alice')!; + expect(alice.profile?.deleted ?? undefined).toBeUndefined(); + expect(alice.profile?.username).toBe('alice'); + }); +}); diff --git a/packages/server/src/utils/federationOutbox.ts b/packages/server/src/utils/federationOutbox.ts index 451bf494..26c240f6 100644 --- a/packages/server/src/utils/federationOutbox.ts +++ b/packages/server/src/utils/federationOutbox.ts @@ -364,6 +364,7 @@ export function getDmParticipants(dmChannelId: string): FederationRelayParticipa avatar: schema.users.avatar, avatarColor: schema.users.avatarColor, status: schema.users.status, + isDeleted: schema.users.isDeleted, }) .from(schema.dmMembers) .innerJoin(schema.users, eq(schema.dmMembers.userId, schema.users.id)) @@ -372,19 +373,30 @@ export function getDmParticipants(dmChannelId: string): FederationRelayParticipa const domainOrigin = getOurOrigin(); - return members.map(m => ({ - homeUserId: m.homeUserId || m.id, - homeInstance: m.homeInstance || domainOrigin, - profile: { - username: m.username ?? null, - displayName: m.displayName ?? null, - avatar: m.avatar ?? null, - avatarColor: m.avatarColor ?? null, - // Only carry presence for native participants — replicated stubs hold - // stale status owned by their home; emitting it would flap remote UIs. - status: !m.homeInstance ? (m.status as 'online' | 'idle' | 'dnd' | 'offline' | null) : null, - }, - })); + return members.map(m => { + if (m.isDeleted) { + // Tombstoned member: ship the identity for attribution but no profile + // data — the internal '!deleted:' marker never leaves this instance. + return { + homeUserId: m.homeUserId || m.id, + homeInstance: m.homeInstance || domainOrigin, + profile: { deleted: true }, + }; + } + return { + homeUserId: m.homeUserId || m.id, + homeInstance: m.homeInstance || domainOrigin, + profile: { + username: m.username ?? null, + displayName: m.displayName ?? null, + avatar: m.avatar ?? null, + avatarColor: m.avatarColor ?? null, + // Only carry presence for native participants — replicated stubs hold + // stale status owned by their home; emitting it would flap remote UIs. + status: !m.homeInstance ? (m.status as 'online' | 'idle' | 'dnd' | 'offline' | null) : null, + }, + }; + }); } /** diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index c4ae5e61..aa7f28bd 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1046,6 +1046,12 @@ export interface FederationRelayProfileSnapshot { // already-online remote stays stuck at 'offline' on the receiver until they // next change status. status?: 'online' | 'idle' | 'dnd' | 'offline' | null; + /** + * The user is tombstoned on the instance that built this snapshot. + * Receivers must not create a new stub for this identity; internal + * '!deleted:' usernames are never shipped (dead-incarnation spec §3.3). + */ + deleted?: boolean | null; } export interface FederationProfileUpdatePayload {