diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 511c3c41..9a50d082 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -26,6 +26,7 @@ import { adminRoutes } from './routes/admin.js'; import { gifRoutes } from './routes/gif.js'; import { federationRoutes } from './routes/federation.js'; import { startFederationWorkers, stopFederationWorkers } from './utils/federationWorker.js'; +import './utils/federationRollback.js'; // Side-effect: registers rollback callbacks for outbox terminal failures. import { registerCallRelayHooks } from './ws/events.js'; import { registerWebSocket } from './ws/handler.js'; diff --git a/packages/server/src/utils/federationRollback.friendRequest.test.ts b/packages/server/src/utils/federationRollback.friendRequest.test.ts new file mode 100644 index 00000000..6ef90dbc --- /dev/null +++ b/packages/server/src/utils/federationRollback.friendRequest.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(1); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +let sqlite: Database.Database; +let testDb: ReturnType>; +const sendToUser = vi.fn(); + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { sendToUser }, +})); + +function applyMigrations(db: Database.Database): void { + const dir = path.resolve(__dirname, '../../drizzle'); + for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) { + const sqlText = fs.readFileSync(path.join(dir, f), 'utf8'); + for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + sendToUser.mockReset(); +}); + +describe('rollbackFriendRequestCreate', () => { + it('deletes the matching friend_requests row and emits friend_request_relay_failed to sender', async () => { + testDb.insert(schema.users).values([ + { id: 'sender', username: 'bob', passwordHash: 'x', status: 'offline', isAdmin: 0, createdAt: Date.now() }, + { id: 'stub', username: 'alice@orbit.test', passwordHash: '!federation-replicated', + status: 'offline', isAdmin: 0, homeInstance: 'orbit.test', homeUserId: 'remote-1', createdAt: Date.now() }, + ] as typeof schema.users.$inferInsert[]).run(); + + testDb.insert(schema.friendRequests).values({ + id: 'req-1', + fromId: 'sender', + toId: 'stub', + status: 'pending', + createdAt: Date.now(), + relayMessageId: 'friend_req:remote-1:sender:1234', + } as typeof schema.friendRequests.$inferInsert).run(); + + const { rollbackFriendRequestCreate } = await import('./federationRollback.js'); + rollbackFriendRequestCreate('friend_req:remote-1:sender:1234', 'recipient_not_found'); + + const remaining = testDb.select().from(schema.friendRequests).where(eq(schema.friendRequests.id, 'req-1')).get(); + expect(remaining).toBeUndefined(); + + expect(sendToUser).toHaveBeenCalledOnce(); + const [userId, event] = sendToUser.mock.calls[0]!; + expect(userId).toBe('sender'); + expect(event.type).toBe('friend_request_relay_failed'); + expect(event.requestId).toBe('req-1'); + expect(event.reason).toBe('user_not_found'); + expect(event.targetHandle).toBe('alice@orbit.test'); + expect(typeof event.message).toBe('string'); + }); + + it('is idempotent: no-op if no row matches the messageId', async () => { + const { rollbackFriendRequestCreate } = await import('./federationRollback.js'); + expect(() => rollbackFriendRequestCreate('no-such-msg', 'recipient_not_found')).not.toThrow(); + expect(sendToUser).not.toHaveBeenCalled(); + }); + + it('maps unknown reasons to peer_rejected', async () => { + testDb.insert(schema.users).values([ + { id: 'sender', username: 'bob', passwordHash: 'x', status: 'offline', isAdmin: 0, createdAt: Date.now() }, + { id: 'stub', username: 'alice@orbit.test', passwordHash: '!federation-replicated', + status: 'offline', isAdmin: 0, homeInstance: 'orbit.test', homeUserId: 'remote-1', createdAt: Date.now() }, + ] as typeof schema.users.$inferInsert[]).run(); + testDb.insert(schema.friendRequests).values({ + id: 'req-2', + fromId: 'sender', + toId: 'stub', + status: 'pending', + createdAt: Date.now(), + relayMessageId: 'msg-x', + } as typeof schema.friendRequests.$inferInsert).run(); + + const { rollbackFriendRequestCreate } = await import('./federationRollback.js'); + rollbackFriendRequestCreate('msg-x', 'attribution_mismatch'); + + expect(sendToUser).toHaveBeenCalledOnce(); + const event = sendToUser.mock.calls[0]![1]; + expect(event.reason).toBe('peer_rejected'); + }); +}); diff --git a/packages/server/src/utils/federationRollback.ts b/packages/server/src/utils/federationRollback.ts index 8512e3d0..c011c6db 100644 --- a/packages/server/src/utils/federationRollback.ts +++ b/packages/server/src/utils/federationRollback.ts @@ -12,6 +12,11 @@ * Errors thrown by callbacks are logged but not re-thrown — rollback failure * must not prevent the outbox entry from being deleted. */ +import { getDb } from '../db/index.js'; +import * as schema from '../db/schema.js'; +import { eq } from 'drizzle-orm'; +import { connectionManager } from '../ws/handler.js'; + type PermanentFailureCallback = (messageId: string, reason: string) => void; const callbacks = new Map(); @@ -37,3 +42,54 @@ export function invokePermanentFailureCallback(eventType: string, messageId: str export function _resetCallbacks(): void { callbacks.clear(); } + +/** + * Rollback handler for 'friend_request_create' outbox events. + * + * Deletes the pending friend request row whose relayMessageId matches the + * failed outbox message, then notifies the sender via WebSocket so the client + * can surface an appropriate error toast. + */ +export function rollbackFriendRequestCreate(messageId: string, receiverReason: string): void { + const db = getDb(); + + const row = db + .select() + .from(schema.friendRequests) + .where(eq(schema.friendRequests.relayMessageId, messageId)) + .get(); + + if (!row) return; // Idempotent — no row to roll back. + + // Look up recipient handle for the toast text BEFORE deleting. + const recipient = db + .select({ username: schema.users.username }) + .from(schema.users) + .where(eq(schema.users.id, row.toId)) + .get(); + const targetHandle = recipient?.username ?? 'unknown'; + + db.delete(schema.friendRequests).where(eq(schema.friendRequests.id, row.id)).run(); + + // Reason mapping: receiver-side reason → client-facing reason. + // 'recipient_not_found' → 'user_not_found' (the looked-up identity vanished) + // anything else → 'peer_rejected' (catch-all) + const reason: 'user_not_found' | 'peer_rejected' = + receiverReason === 'recipient_not_found' ? 'user_not_found' : 'peer_rejected'; + + const message = + reason === 'user_not_found' + ? `User ${targetHandle} no longer exists on the remote instance.` + : `Friend request to ${targetHandle} was rejected by the remote instance.`; + + connectionManager.sendToUser(row.fromId, { + type: 'friend_request_relay_failed', + requestId: row.id, + reason, + message, + targetHandle, + }); +} + +// Register the callback at module-load time so the worker invokes it. +registerPermanentFailureCallback('friend_request_create', rollbackFriendRequestCreate);