From d3d6e5af039c5c921a2c4835ef62cd48b6c624d4 Mon Sep 17 00:00:00 2001 From: "Huncho.Dev" Date: Sun, 28 Jun 2026 04:28:33 +0000 Subject: [PATCH 1/4] #221 Presence recovery after gateway restart FIXED --- .../__tests__/presence.reconciliation.test.ts | 165 ++++++++++++++++++ apps/backend/src/index.ts | 54 +++++- apps/backend/src/routes/conversations.ts | 7 +- apps/backend/src/routes/treasury.ts | 3 +- apps/backend/src/services/presence.ts | 79 +++++++++ 5 files changed, 303 insertions(+), 5 deletions(-) create mode 100644 apps/backend/src/__tests__/presence.reconciliation.test.ts diff --git a/apps/backend/src/__tests__/presence.reconciliation.test.ts b/apps/backend/src/__tests__/presence.reconciliation.test.ts new file mode 100644 index 0000000..08f7649 --- /dev/null +++ b/apps/backend/src/__tests__/presence.reconciliation.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { reconcileBoot, cleanupStaleSockets, setOffline } from '../services/presence.js'; + +// ── DB mock ──────────────────────────────────────────────────────────────── +const { mockFindMany } = vi.hoisted(() => ({ + mockFindMany: vi.fn(), +})); + +vi.mock('../db/index.js', () => ({ + db: { + query: { + conversationMembers: { findMany: mockFindMany }, + }, + }, +})); + +vi.mock('../db/schema.js', () => ({ + conversationMembers: { + userId: 'userId', + conversationId: 'conversationId', + }, +})); + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((col: unknown, val: unknown) => ({ col, val })), +})); + +// ── Redis & Socket mock ──────────────────────────────────────────────────── + +describe('Presence Reconciliation & Gateway Boot (#...)', () => { + let mockRedis: any; + let mockIo: any; + let mockSocketsJoin: any; + let mockFetchSockets: any; + + beforeEach(() => { + vi.clearAllMocks(); + + mockSocketsJoin = vi.fn(); + mockFetchSockets = vi.fn().mockResolvedValue([]); + + mockIo = { + in: vi.fn((sid: string) => ({ + socketsJoin: mockSocketsJoin, + fetchSockets: () => mockFetchSockets(sid), + })), + }; + + mockRedis = { + scan: vi.fn(), + keys: vi.fn(), + smembers: vi.fn(), + srem: vi.fn(), + scard: vi.fn(), + del: vi.fn(), + }; + }); + + describe('reconcileBoot', () => { + it('rebuilds room subscriptions from active Redis socket mappings on boot', async () => { + // redis.scan returns presence keys + mockRedis.scan + .mockResolvedValueOnce(['10', ['presence:user-1', 'presence:user-2']]) + .mockResolvedValueOnce(['0', []]); + + mockRedis.smembers.mockImplementation(async (key: string) => { + if (key === 'presence:user-1') return ['socket-1a', 'socket-1b']; + if (key === 'presence:user-2') return ['socket-2a']; + return []; + }); + + mockFindMany.mockImplementation(async ({ where }: any) => { + if (where.val === 'user-1') { + return [{ conversationId: 'room-alpha' }, { conversationId: 'room-beta' }]; + } + if (where.val === 'user-2') { + return [{ conversationId: 'room-gamma' }]; + } + return []; + }); + + await reconcileBoot(mockIo as any, mockRedis as any); + + expect(mockRedis.scan).toHaveBeenCalledTimes(2); + expect(mockFindMany).toHaveBeenCalledTimes(2); + + // user-1 sockets joined room-alpha & room-beta + expect(mockIo.in).toHaveBeenCalledWith('socket-1a'); + expect(mockIo.in).toHaveBeenCalledWith('socket-1b'); + expect(mockIo.in).toHaveBeenCalledWith('socket-2a'); + expect(mockSocketsJoin).toHaveBeenCalledWith('room-alpha'); + expect(mockSocketsJoin).toHaveBeenCalledWith('room-beta'); + expect(mockSocketsJoin).toHaveBeenCalledWith('room-gamma'); + }); + + it('falls back to redis.keys if redis.scan throws', async () => { + mockRedis.scan.mockRejectedValue(new Error('scan not supported')); + mockRedis.keys.mockResolvedValue(['presence:user-3']); + mockRedis.smembers.mockResolvedValue(['socket-3a']); + mockFindMany.mockResolvedValue([{ conversationId: 'room-delta' }]); + + await reconcileBoot(mockIo as any, mockRedis as any); + + expect(mockRedis.keys).toHaveBeenCalledWith('presence:*'); + expect(mockSocketsJoin).toHaveBeenCalledWith('room-delta'); + }); + }); + + describe('cleanupStaleSockets', () => { + it('removes stale socket IDs from Redis presence set and deletes empty sets', async () => { + mockRedis.smembers.mockResolvedValue(['socket-dead', 'socket-alive']); + + mockFetchSockets.mockImplementation(async (sid: string) => { + if (sid === 'socket-alive') return [{ id: 'socket-alive' }]; // still connected + return []; // dead socket + }); + + mockRedis.scard.mockResolvedValue(1); + + await cleanupStaleSockets(mockIo as any, mockRedis as any, 'user-1'); + + expect(mockRedis.srem).toHaveBeenCalledWith('presence:user-1', 'socket-dead'); + expect(mockRedis.srem).not.toHaveBeenCalledWith('presence:user-1', 'socket-alive'); + expect(mockRedis.del).not.toHaveBeenCalled(); + }); + + it('deletes presence key if all sockets were stale and removed', async () => { + mockRedis.smembers.mockResolvedValue(['socket-dead-1']); + mockFetchSockets.mockResolvedValue([]); // dead socket + mockRedis.scard.mockResolvedValue(0); + + await cleanupStaleSockets(mockIo as any, mockRedis as any, 'user-2'); + + expect(mockRedis.srem).toHaveBeenCalledWith('presence:user-2', 'socket-dead-1'); + expect(mockRedis.del).toHaveBeenCalledWith('presence:user-2'); + }); + + it('ignores activeSocketId if passed', async () => { + mockRedis.smembers.mockResolvedValue(['socket-new']); + + await cleanupStaleSockets(mockIo as any, mockRedis as any, 'user-3', 'socket-new'); + + expect(mockFetchSockets).not.toHaveBeenCalled(); + expect(mockRedis.srem).not.toHaveBeenCalled(); + }); + }); + + describe('setOffline', () => { + it('removes socket ID and returns true when no sockets remain', async () => { + mockRedis.scard.mockResolvedValue(0); + const offline = await setOffline(mockRedis as any, 'user-1', 'socket-1'); + expect(mockRedis.srem).toHaveBeenCalledWith('presence:user-1', 'socket-1'); + expect(mockRedis.del).toHaveBeenCalledWith('presence:user-1'); + expect(offline).toBe(true); + }); + + it('returns false when surviving connections remain', async () => { + mockRedis.scard.mockResolvedValue(1); + const offline = await setOffline(mockRedis as any, 'user-1', 'socket-1'); + expect(mockRedis.srem).toHaveBeenCalledWith('presence:user-1', 'socket-1'); + expect(mockRedis.del).not.toHaveBeenCalled(); + expect(offline).toBe(false); + }); + }); +}); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index f8d60b7..ea17bd0 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -11,7 +11,13 @@ import { registerMessagingHandlers } from './socket/messaging.js'; import { app } from './app.js'; import { redis as appRedis } from './lib/redis.js'; import { setSocketServer } from './lib/socket.js'; -import { setOnline, setOffline, refreshPresence } from './services/presence.js'; +import { + setOnline, + setOffline, + refreshPresence, + reconcileBoot, + cleanupStaleSockets, +} from './services/presence.js'; import { buildRpcFetcher, buildTreasuryRpcFetcher, @@ -30,6 +36,27 @@ const io = new Server(httpServer, { cors: { origin: '*' }, }); +let isShuttingDown = false; + +const handleShutdown = () => { + isShuttingDown = true; +}; + +process.on('SIGTERM', handleShutdown); +process.on('SIGINT', handleShutdown); + +const origIoClose = io.close.bind(io); +io.close = ((fn?: () => void) => { + isShuttingDown = true; + return origIoClose(fn); +}) as typeof io.close; + +const origHttpClose = httpServer.close.bind(httpServer); +httpServer.close = ((fn?: (err?: Error) => void) => { + isShuttingDown = true; + return origHttpClose(fn); +}) as typeof httpServer.close; + setSocketServer(io); io.use(socketAuthMiddleware); @@ -49,6 +76,7 @@ io.on('connection', async (socket: AuthSocket) => { } if (appRedis) { + await cleanupStaleSockets(io, appRedis, userId, socket.id); await setOnline(appRedis, userId, socket.id); for (const m of memberships) { io.to(m.conversationId).emit('user_online', { userId }); @@ -58,15 +86,24 @@ io.on('connection', async (socket: AuthSocket) => { socket.on('heartbeat', async () => { if (appRedis) { + await cleanupStaleSockets(io, appRedis, userId, socket.id); await refreshPresence(appRedis, userId); } }); registerMessagingHandlers(io, socket); - socket.on('disconnect', async () => { - console.log('User disconnected:', userId); + socket.on('disconnect', async (reason: string) => { + console.log('User disconnected:', userId, reason); + if ( + isShuttingDown || + reason === 'server shutting down' || + reason === 'server namespace disconnect' + ) { + return; + } if (appRedis) { + await cleanupStaleSockets(io, appRedis, userId, socket.id); const fullyOffline = await setOffline(appRedis, userId, socket.id); if (fullyOffline) { const memberships = await db.query.conversationMembers.findMany({ @@ -111,6 +148,15 @@ async function attachRedisAdapter(): Promise { const message = err instanceof Error ? err.message : String(err); console.warn(`[socket.io] Redis unavailable (${message}) — running in single-instance mode`); await Promise.allSettled([pubClient.quit(), subClient.quit()]); + } finally { + if (appRedis) { + try { + await reconcileBoot(io, appRedis); + console.log('[presence] Boot reconciliation complete'); + } catch (err) { + console.warn('[presence] Boot reconciliation failed:', err); + } + } } } @@ -149,3 +195,5 @@ if (stellarRpcUrl && tokenTransferContractId) { '[stellar-listener] STELLAR_RPC_URL or TOKEN_TRANSFER_CONTRACT_ID unset; listener disabled.', ); } + +export { httpServer, io }; diff --git a/apps/backend/src/routes/conversations.ts b/apps/backend/src/routes/conversations.ts index 822070f..8b9a0d0 100644 --- a/apps/backend/src/routes/conversations.ts +++ b/apps/backend/src/routes/conversations.ts @@ -95,7 +95,12 @@ conversationsRouter.get('/', async (req: AuthRequest, res) => { with: { conversation: conversationRelations as never, }, - })) as unknown as Array<{ conversationId: string; conversation: ConversationPayload }>; + })) as unknown as Array<{ + conversationId: string; + isMuted: boolean; + isArchived: boolean; + conversation: ConversationPayload; + }>; // Single subquery for message counts — no N+1 const conversationIds = memberships.map((m) => m.conversationId); diff --git a/apps/backend/src/routes/treasury.ts b/apps/backend/src/routes/treasury.ts index 660f768..e66acc5 100644 --- a/apps/backend/src/routes/treasury.ts +++ b/apps/backend/src/routes/treasury.ts @@ -1,9 +1,10 @@ import { Router } from 'express'; +import type { IRouter } from 'express'; import { z } from 'zod'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; -export const treasuryRouter = Router(); +export const treasuryRouter: IRouter = Router(); treasuryRouter.use(requireAuth); diff --git a/apps/backend/src/services/presence.ts b/apps/backend/src/services/presence.ts index ccda9cd..cf4a250 100644 --- a/apps/backend/src/services/presence.ts +++ b/apps/backend/src/services/presence.ts @@ -10,7 +10,11 @@ * - On disconnect: remove socketId from set, if set empty → user_offline * - GET /users/:id/presence → { online: boolean } */ +import type { Server } from 'socket.io'; import type { Redis } from 'ioredis'; +import { eq } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { conversationMembers } from '../db/schema.js'; const PRESENCE_TTL = 60; // seconds @@ -62,3 +66,78 @@ export async function isOnline(redis: Redis, userId: string): Promise { const count = await redis.scard(key); return count > 0; } + +/** + * Remove any socket IDs in the user's presence set that are no longer + * connected anywhere in the Socket.IO cluster. + */ +export async function cleanupStaleSockets( + io: Server, + redis: Redis, + userId: string, + ignoredSocketId?: string, +): Promise { + const key = presenceKey(userId); + const socketIds = await redis.smembers(key); + if (socketIds.length === 0) return; + + await Promise.all( + socketIds.map(async (sid) => { + if (ignoredSocketId && sid === ignoredSocketId) return; + try { + const sockets = await io.in(sid).fetchSockets(); + if (sockets.length === 0) { + await redis.srem(key, sid); + } + } catch (err) { + console.warn(`[presence] Failed to check socket status for ${sid}:`, err); + } + }), + ); + + const remaining = await redis.scard(key); + if (remaining === 0) { + await redis.del(key); + } +} + +/** + * Rebuild room subscriptions from active Redis socket mappings on gateway boot. + */ +export async function reconcileBoot(io: Server, redis: Redis): Promise { + let presenceKeys: string[]; + try { + let cursor = '0'; + presenceKeys = []; + do { + const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', 'presence:*', 'COUNT', 100); + cursor = nextCursor; + presenceKeys.push(...keys); + } while (cursor !== '0'); + } catch { + presenceKeys = await redis.keys('presence:*'); + } + + for (const key of presenceKeys) { + const userId = key.slice('presence:'.length); + if (!userId) continue; + + const socketIds = await redis.smembers(key); + if (socketIds.length === 0) continue; + + try { + const memberships = await db.query.conversationMembers.findMany({ + where: eq(conversationMembers.userId, userId), + columns: { conversationId: true }, + }); + + for (const socketId of socketIds) { + for (const m of memberships) { + io.in(socketId).socketsJoin(m.conversationId); + } + } + } catch (err) { + console.warn(`[presence] Failed to rebuild subscriptions for ${userId}:`, err); + } + } +} From f23f00c5adf23d7856d9bba74348285de0e0917f Mon Sep 17 00:00:00 2001 From: "Huncho.Dev" Date: Mon, 29 Jun 2026 04:27:32 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20resolve=20merge=20conflicts=20in=20i?= =?UTF-8?q?ndex.ts=20and=20treasury.ts=20=E2=80=93=20CI=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/index.ts | 121 +++++++++++++++++++++++++--- apps/backend/src/routes/treasury.ts | 4 +- 2 files changed, 113 insertions(+), 12 deletions(-) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index ea17bd0..195888e 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -5,12 +5,13 @@ import { createClient } from 'redis'; import dotenv from 'dotenv'; import { eq } from 'drizzle-orm'; import { db } from './db/index.js'; -import { conversationMembers } from './db/schema.js'; +import { conversationMembers, users } from './db/schema.js'; import { socketAuthMiddleware, type AuthSocket } from './middleware/socketAuth.js'; import { registerMessagingHandlers } from './socket/messaging.js'; import { app } from './app.js'; import { redis as appRedis } from './lib/redis.js'; import { setSocketServer } from './lib/socket.js'; + import { setOnline, setOffline, @@ -18,6 +19,22 @@ import { reconcileBoot, cleanupStaleSockets, } from './services/presence.js'; + +import { startHeartbeatTimer, clearHeartbeatTimer } from './services/heartbeat.js'; +import { + registerDeviceSocket, + unregisterDeviceSocket, + isDeviceRevoked, + startDeviceRevocationListener, +} from './services/deviceRevocation.js'; +import { + checkRateLimit, + checkPayloadSize, + recordViolation, + clearViolations, +} from './services/rateLimit.js'; +import { registerForBackpressure, unregisterForBackpressure } from './services/backpressure.js'; + import { buildRpcFetcher, buildTreasuryRpcFetcher, @@ -63,8 +80,57 @@ io.use(socketAuthMiddleware); io.on('connection', async (socket: AuthSocket) => { const userId = socket.auth!.userId; + const deviceId = socket.auth!.deviceId; console.log('User connected:', userId, socket.id); + // Register socket for device-revocation tracking (cross-instance via Redis pub/sub). + if (appRedis) { + registerDeviceSocket(deviceId, socket.id); + } + + // Start the server-side heartbeat watchdog (90 s timeout). + startHeartbeatTimer(socket, userId, deviceId, appRedis, io); + + // Per-socket middleware: intercept every incoming event before handlers. + const EXCLUDED_EVENTS = new Set(['heartbeat']); + socket.use(async ([event, ...args], next) => { + // Skip internal heartbeat pings. + if (EXCLUDED_EVENTS.has(event)) { + return next(); + } + + // Reject events from a device that was revoked mid-session. + if (isDeviceRevoked(deviceId)) { + socket.emit('error', { event: 'device_revoked', message: 'Device has been revoked' }); + socket.disconnect(true); + return; + } + + // Enforce maximum payload size (configurable via MAX_PAYLOAD_SIZE env). + const payloadArgs = args.filter((a) => typeof a !== 'function'); + const { valid, size } = checkPayloadSize(payloadArgs); + if (!valid) { + socket.emit('error', { + event: 'payload_too_large', + message: `Payload size ${size} exceeds limit`, + }); + return; + } + + // Per-socket rate limiting (configurable via SOCKET_RATE_LIMIT_PER_SEC env). + const { allowed } = await checkRateLimit(appRedis, socket.id); + if (!allowed) { + const violations = recordViolation(socket.id); + socket.emit('error', { event: 'rate_limited', message: 'Rate limit exceeded' }); + if (violations >= 3) { + socket.disconnect(true); + } + return; + } + + next(); + }); + // Auto-join all conversation rooms so the socket receives new_message events // for every conversation the user belongs to (needed for unread badge tracking). const memberships = await db.query.conversationMembers.findMany({ @@ -75,12 +141,20 @@ io.on('connection', async (socket: AuthSocket) => { await socket.join(m.conversationId); } + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + columns: { presenceVisible: true }, + }); + const presenceVisible = user?.presenceVisible ?? true; + if (appRedis) { await cleanupStaleSockets(io, appRedis, userId, socket.id); await setOnline(appRedis, userId, socket.id); - for (const m of memberships) { - io.to(m.conversationId).emit('user_online', { userId }); - io.to(m.conversationId).emit('presence_update', { userId, online: true }); + if (presenceVisible) { + for (const m of memberships) { + io.to(m.conversationId).emit('user_online', { userId }); + io.to(m.conversationId).emit('presence_update', { userId, online: true }); + } } } @@ -93,8 +167,19 @@ io.on('connection', async (socket: AuthSocket) => { registerMessagingHandlers(io, socket); + // Monitor send-buffer to detect slow/stalled consumers. + registerForBackpressure(socket); + socket.on('disconnect', async (reason: string) => { console.log('User disconnected:', userId, reason); + clearHeartbeatTimer(socket.id); + unregisterDeviceSocket(socket.id); + unregisterForBackpressure(socket); + clearViolations(socket.id); + + // During a gateway restart we must NOT wipe presence — surviving + // devices re-assert via heartbeat and Redis TTLs. This satisfies + // #221: Gateway restart does not drop still-connected users to offline. if ( isShuttingDown || reason === 'server shutting down' || @@ -102,17 +187,26 @@ io.on('connection', async (socket: AuthSocket) => { ) { return; } + if (appRedis) { await cleanupStaleSockets(io, appRedis, userId, socket.id); const fullyOffline = await setOffline(appRedis, userId, socket.id); if (fullyOffline) { - const memberships = await db.query.conversationMembers.findMany({ - where: eq(conversationMembers.userId, userId), - columns: { conversationId: true }, + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + columns: { presenceVisible: true }, }); - for (const m of memberships) { - io.to(m.conversationId).emit('user_offline', { userId }); - io.to(m.conversationId).emit('presence_update', { userId, online: false }); + const presenceVisible = user?.presenceVisible ?? true; + + if (presenceVisible) { + const memberships = await db.query.conversationMembers.findMany({ + where: eq(conversationMembers.userId, userId), + columns: { conversationId: true }, + }); + for (const m of memberships) { + io.to(m.conversationId).emit('user_offline', { userId }); + io.to(m.conversationId).emit('presence_update', { userId, online: false }); + } } } } @@ -169,6 +263,13 @@ httpServer.listen(PORT, () => { // Redis is unreachable; on failure we fall back to the in-process adapter. void attachRedisAdapter(); +// Subscribe to device_revoked:* channels so any gateway instance can +// disconnect a revoked device's sockets within seconds, even when the +// revocation was issued on a different node. +if (appRedis) { + void startDeviceRevocationListener(appRedis, appRedis); +} + // #46 — Stellar transfer event listener. Only spin up when the contract // id is configured so local-dev and unit-test runs don't try to talk to // Soroban RPC. The listener never throws out of runForever, so a failed diff --git a/apps/backend/src/routes/treasury.ts b/apps/backend/src/routes/treasury.ts index e66acc5..9a9363f 100644 --- a/apps/backend/src/routes/treasury.ts +++ b/apps/backend/src/routes/treasury.ts @@ -1,5 +1,5 @@ -import { Router } from 'express'; -import type { IRouter } from 'express'; +import { Router, type IRouter } from 'express'; + import { z } from 'zod'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; From 9fae92a5a4917b568866faa96e4728a90747e9b5 Mon Sep 17 00:00:00 2001 From: "Huncho.Dev" Date: Tue, 30 Jun 2026 20:05:07 +0000 Subject: [PATCH 3/4] fix: resolve presence reconciliation CI failures --- .../src/__tests__/devices.prekeys.test.ts | 2 +- .../__tests__/presence.reconciliation.test.ts | 144 ++++++++++----- apps/backend/src/index.ts | 72 ++++---- .../backend/src/lib/validateMessagePayload.ts | 11 +- apps/backend/src/routes/treasury.ts | 3 - apps/backend/src/services/heartbeat.ts | 24 ++- apps/backend/src/services/presence.ts | 167 ++++++++++++++++-- apps/backend/src/socket/messaging.ts | 24 ++- apps/web/next.config.ts | 4 +- apps/web/src/app/app/layout.tsx | 20 +-- .../src/components/PushPermissionPrompt.tsx | 18 +- apps/web/src/hooks/usePushSubscription.ts | 32 ++-- 12 files changed, 364 insertions(+), 157 deletions(-) diff --git a/apps/backend/src/__tests__/devices.prekeys.test.ts b/apps/backend/src/__tests__/devices.prekeys.test.ts index dbf977d..b67ebf4 100644 --- a/apps/backend/src/__tests__/devices.prekeys.test.ts +++ b/apps/backend/src/__tests__/devices.prekeys.test.ts @@ -131,7 +131,7 @@ describe('POST /devices/:id/prekeys', () => { it('returns 400 when signed prekey signature is invalid', async () => { mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); // Override the crypto mock to return false for this test. - vi.mocked(cryptoVerify).mockReturnValueOnce(false); + vi.mocked(cryptoVerify).mockReturnValueOnce(false as never); const res = await request(makeApp()).post('/devices/device-1/prekeys').send(VALID_BODY); diff --git a/apps/backend/src/__tests__/presence.reconciliation.test.ts b/apps/backend/src/__tests__/presence.reconciliation.test.ts index 08f7649..9062a89 100644 --- a/apps/backend/src/__tests__/presence.reconciliation.test.ts +++ b/apps/backend/src/__tests__/presence.reconciliation.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { reconcileBoot, cleanupStaleSockets, setOffline } from '../services/presence.js'; +import { + cleanupStaleSockets, + reconcileBoot, + registerPresenceSocket, + setOffline, + unregisterPresenceSocket, +} from '../services/presence.js'; // ── DB mock ──────────────────────────────────────────────────────────────── const { mockFindMany } = vi.hoisted(() => ({ @@ -27,7 +33,7 @@ vi.mock('drizzle-orm', () => ({ // ── Redis & Socket mock ──────────────────────────────────────────────────── -describe('Presence Reconciliation & Gateway Boot (#...)', () => { +describe('Presence Reconciliation & Gateway Boot (#221)', () => { let mockRedis: any; let mockIo: any; let mockSocketsJoin: any; @@ -37,7 +43,7 @@ describe('Presence Reconciliation & Gateway Boot (#...)', () => { vi.clearAllMocks(); mockSocketsJoin = vi.fn(); - mockFetchSockets = vi.fn().mockResolvedValue([]); + mockFetchSockets = vi.fn().mockResolvedValue([{ id: 'socket-active' }]); mockIo = { in: vi.fn((sid: string) => ({ @@ -50,26 +56,32 @@ describe('Presence Reconciliation & Gateway Boot (#...)', () => { scan: vi.fn(), keys: vi.fn(), smembers: vi.fn(), - srem: vi.fn(), - scard: vi.fn(), - del: vi.fn(), + srem: vi.fn().mockResolvedValue(1), + sadd: vi.fn().mockResolvedValue(1), + scard: vi.fn().mockResolvedValue(1), + del: vi.fn().mockResolvedValue(1), + hset: vi.fn().mockResolvedValue(1), + hgetall: vi.fn().mockResolvedValue({ deviceId: 'device-1' }), + hdel: vi.fn().mockResolvedValue(1), + hlen: vi.fn().mockResolvedValue(1), + expire: vi.fn().mockResolvedValue(true), }; }); describe('reconcileBoot', () => { it('rebuilds room subscriptions from active Redis socket mappings on boot', async () => { - // redis.scan returns presence keys + // redis.scan returns user socket-mapping keys, not device presence hashes. mockRedis.scan - .mockResolvedValueOnce(['10', ['presence:user-1', 'presence:user-2']]) + .mockResolvedValueOnce(['10', ['presence:sockets:user-1', 'presence:sockets:user-2']]) .mockResolvedValueOnce(['0', []]); mockRedis.smembers.mockImplementation(async (key: string) => { - if (key === 'presence:user-1') return ['socket-1a', 'socket-1b']; - if (key === 'presence:user-2') return ['socket-2a']; + if (key === 'presence:sockets:user-1') return ['socket-1a', 'socket-1b']; + if (key === 'presence:sockets:user-2') return ['socket-2a']; return []; }); - mockFindMany.mockImplementation(async ({ where }: any) => { + mockFindMany.mockImplementation(async ({ where }: { where: { val: string } }) => { if (where.val === 'user-1') { return [{ conversationId: 'room-alpha' }, { conversationId: 'room-beta' }]; } @@ -79,12 +91,11 @@ describe('Presence Reconciliation & Gateway Boot (#...)', () => { return []; }); - await reconcileBoot(mockIo as any, mockRedis as any); + await reconcileBoot(mockIo as never, mockRedis as never); expect(mockRedis.scan).toHaveBeenCalledTimes(2); expect(mockFindMany).toHaveBeenCalledTimes(2); - // user-1 sockets joined room-alpha & room-beta expect(mockIo.in).toHaveBeenCalledWith('socket-1a'); expect(mockIo.in).toHaveBeenCalledWith('socket-1b'); expect(mockIo.in).toHaveBeenCalledWith('socket-2a'); @@ -95,70 +106,121 @@ describe('Presence Reconciliation & Gateway Boot (#...)', () => { it('falls back to redis.keys if redis.scan throws', async () => { mockRedis.scan.mockRejectedValue(new Error('scan not supported')); - mockRedis.keys.mockResolvedValue(['presence:user-3']); + mockRedis.keys.mockResolvedValue(['presence:sockets:user-3']); mockRedis.smembers.mockResolvedValue(['socket-3a']); mockFindMany.mockResolvedValue([{ conversationId: 'room-delta' }]); - await reconcileBoot(mockIo as any, mockRedis as any); + await reconcileBoot(mockIo as never, mockRedis as never); - expect(mockRedis.keys).toHaveBeenCalledWith('presence:*'); + expect(mockRedis.keys).toHaveBeenCalledWith('presence:sockets:*'); expect(mockSocketsJoin).toHaveBeenCalledWith('room-delta'); }); }); describe('cleanupStaleSockets', () => { - it('removes stale socket IDs from Redis presence set and deletes empty sets', async () => { + it('removes stale socket IDs from Redis socket mappings and keeps active sockets', async () => { mockRedis.smembers.mockResolvedValue(['socket-dead', 'socket-alive']); mockFetchSockets.mockImplementation(async (sid: string) => { - if (sid === 'socket-alive') return [{ id: 'socket-alive' }]; // still connected - return []; // dead socket + if (sid === 'socket-alive') return [{ id: 'socket-alive' }]; + return []; + }); + mockRedis.hgetall.mockResolvedValue({ deviceId: 'device-1' }); + mockRedis.scard.mockImplementation(async (key: string) => { + if (key === 'presence:sockets:user-1') return 1; + return 0; }); - mockRedis.scard.mockResolvedValue(1); - - await cleanupStaleSockets(mockIo as any, mockRedis as any, 'user-1'); + await cleanupStaleSockets(mockIo as never, mockRedis as never, 'user-1'); - expect(mockRedis.srem).toHaveBeenCalledWith('presence:user-1', 'socket-dead'); - expect(mockRedis.srem).not.toHaveBeenCalledWith('presence:user-1', 'socket-alive'); - expect(mockRedis.del).not.toHaveBeenCalled(); + expect(mockRedis.srem).toHaveBeenCalledWith('presence:sockets:user-1', 'socket-dead'); + expect(mockRedis.srem).toHaveBeenCalledWith( + 'presence:device_sockets:user-1:device-1', + 'socket-dead', + ); + expect(mockRedis.srem).not.toHaveBeenCalledWith('presence:sockets:user-1', 'socket-alive'); + expect(mockRedis.del).toHaveBeenCalledWith('presence:socket:socket-dead'); + expect(mockRedis.del).not.toHaveBeenCalledWith('presence:sockets:user-1'); }); - it('deletes presence key if all sockets were stale and removed', async () => { + it('deletes socket mapping key if all sockets were stale and removed', async () => { mockRedis.smembers.mockResolvedValue(['socket-dead-1']); - mockFetchSockets.mockResolvedValue([]); // dead socket + mockFetchSockets.mockResolvedValue([]); + mockRedis.hgetall.mockResolvedValue({ deviceId: 'device-1' }); mockRedis.scard.mockResolvedValue(0); - await cleanupStaleSockets(mockIo as any, mockRedis as any, 'user-2'); + await cleanupStaleSockets(mockIo as never, mockRedis as never, 'user-2'); - expect(mockRedis.srem).toHaveBeenCalledWith('presence:user-2', 'socket-dead-1'); - expect(mockRedis.del).toHaveBeenCalledWith('presence:user-2'); + expect(mockRedis.srem).toHaveBeenCalledWith('presence:sockets:user-2', 'socket-dead-1'); + expect(mockRedis.del).toHaveBeenCalledWith('presence:sockets:user-2'); }); it('ignores activeSocketId if passed', async () => { mockRedis.smembers.mockResolvedValue(['socket-new']); - await cleanupStaleSockets(mockIo as any, mockRedis as any, 'user-3', 'socket-new'); + await cleanupStaleSockets(mockIo as never, mockRedis as never, 'user-3', 'socket-new'); expect(mockFetchSockets).not.toHaveBeenCalled(); expect(mockRedis.srem).not.toHaveBeenCalled(); }); }); + describe('socket mapping helpers', () => { + it('registers a socket without duplicating device-level presence entries', async () => { + await registerPresenceSocket(mockRedis as never, 'user-1', 'device-1', 'socket-1'); + + expect(mockRedis.sadd).toHaveBeenCalledWith('presence:sockets:user-1', 'socket-1'); + expect(mockRedis.sadd).toHaveBeenCalledWith( + 'presence:device_sockets:user-1:device-1', + 'socket-1', + ); + expect(mockRedis.hset).toHaveBeenCalledWith('presence:socket:socket-1', { + userId: 'user-1', + deviceId: 'device-1', + }); + }); + + it('unregisters a socket and reports whether the device has no sockets left', async () => { + mockRedis.scard.mockImplementation(async (key: string) => { + if (key === 'presence:device_sockets:user-1:device-1') return 0; + return 1; + }); + + const deviceHasNoSockets = await unregisterPresenceSocket( + mockRedis as never, + 'user-1', + 'device-1', + 'socket-1', + ); + + expect(mockRedis.srem).toHaveBeenCalledWith('presence:sockets:user-1', 'socket-1'); + expect(mockRedis.srem).toHaveBeenCalledWith( + 'presence:device_sockets:user-1:device-1', + 'socket-1', + ); + expect(mockRedis.del).toHaveBeenCalledWith('presence:socket:socket-1'); + expect(deviceHasNoSockets).toBe(true); + }); + }); + describe('setOffline', () => { - it('removes socket ID and returns true when no sockets remain', async () => { - mockRedis.scard.mockResolvedValue(0); - const offline = await setOffline(mockRedis as any, 'user-1', 'socket-1'); - expect(mockRedis.srem).toHaveBeenCalledWith('presence:user-1', 'socket-1'); - expect(mockRedis.del).toHaveBeenCalledWith('presence:user-1'); + it('removes device ID and returns true when no devices remain', async () => { + mockRedis.hlen.mockResolvedValue(0); + + const offline = await setOffline(mockRedis as never, 'user-1', 'device-1'); + + expect(mockRedis.hdel).toHaveBeenCalledWith('presence:user:user-1', 'device-1'); + expect(mockRedis.del).toHaveBeenCalledWith('presence:user:user-1'); expect(offline).toBe(true); }); - it('returns false when surviving connections remain', async () => { - mockRedis.scard.mockResolvedValue(1); - const offline = await setOffline(mockRedis as any, 'user-1', 'socket-1'); - expect(mockRedis.srem).toHaveBeenCalledWith('presence:user-1', 'socket-1'); - expect(mockRedis.del).not.toHaveBeenCalled(); + it('returns false when surviving devices remain', async () => { + mockRedis.hlen.mockResolvedValue(1); + + const offline = await setOffline(mockRedis as never, 'user-1', 'device-1'); + + expect(mockRedis.hdel).toHaveBeenCalledWith('presence:user:user-1', 'device-1'); + expect(mockRedis.del).not.toHaveBeenCalledWith('presence:user:user-1'); expect(offline).toBe(false); }); }); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 34cf52d..34c433a 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -12,37 +12,30 @@ import { registerMessagingHandlers } from './socket/messaging.js'; import { app } from './app.js'; import { redis as appRedis } from './lib/redis.js'; import { setSocketServer } from './lib/socket.js'; - - import { - setOnline, - setOffline, - refreshPresence, - reconcileBoot, cleanupStaleSockets, + reconcileBoot, + refreshPresenceSocket, + registerPresenceSocket, + setOffline, + setOnline, + unregisterPresenceSocket, } from './services/presence.js'; - - -import { setOnline, setOffline } from './services/presence.js'; - import { startHeartbeatTimer, clearHeartbeatTimer } from './services/heartbeat.js'; import { - registerDeviceSocket, - unregisterDeviceSocket, isDeviceRevoked, + registerDeviceSocket, startDeviceRevocationListener, + unregisterDeviceSocket, } from './services/deviceRevocation.js'; import { - checkRateLimit, checkPayloadSize, - recordViolation, + checkRateLimit, clearViolations, + recordViolation, } from './services/rateLimit.js'; import { registerForBackpressure, unregisterForBackpressure } from './services/backpressure.js'; - - import { getGatewaySubscriber } from './services/deviceDelivery.js'; - import { buildRpcFetcher, buildTreasuryRpcFetcher, @@ -96,10 +89,12 @@ async function recordPresenceForCoMembers( if (!appRedis || conversationIds.length === 0) { return; } + const coMembers = await db.query.conversationMembers.findMany({ where: inArray(conversationMembers.conversationId, conversationIds), columns: { userId: true }, }); + await publishEphemeral( appRedis, coMembers.map((m) => m.userId).filter((id) => id !== userId), @@ -114,10 +109,11 @@ io.on('connection', async (socket: AuthSocket) => { const deviceId = socket.auth!.deviceId; console.log('User connected:', userId, socket.id); + socket.data['userId'] = userId; + socket.data['deviceId'] = deviceId; + // Register socket for device-revocation tracking (cross-instance via Redis pub/sub). - if (appRedis) { - registerDeviceSocket(deviceId, socket.id); - } + registerDeviceSocket(deviceId, socket.id); // Start the server-side heartbeat watchdog (90 s timeout). startHeartbeatTimer(socket, userId, deviceId, appRedis, io); @@ -184,14 +180,11 @@ io.on('connection', async (socket: AuthSocket) => { const presenceVisible = user?.presenceVisible ?? true; if (appRedis) { - + await registerPresenceSocket(appRedis, userId, deviceId, socket.id); await cleanupStaleSockets(io, appRedis, userId, socket.id); - await setOnline(appRedis, userId, socket.id); - if (presenceVisible) { const becameOnline = await setOnline(appRedis, userId, deviceId); if (becameOnline && presenceVisible) { - for (const m of memberships) { io.to(m.conversationId).emit('user_online', { userId }); io.to(m.conversationId).emit('presence_update', { userId, online: true }); @@ -204,15 +197,13 @@ io.on('connection', async (socket: AuthSocket) => { } } - socket.on('heartbeat', async () => { if (appRedis) { + await refreshPresenceSocket(appRedis, userId, deviceId, socket.id); await cleanupStaleSockets(io, appRedis, userId, socket.id); - await refreshPresence(appRedis, userId); } }); - registerMessagingHandlers(io, socket); // Subscribe to the device delivery channel so cross-node per-device @@ -231,13 +222,9 @@ io.on('connection', async (socket: AuthSocket) => { // Monitor send-buffer to detect slow/stalled consumers. registerForBackpressure(socket); - socket.on('disconnect', async (reason: string) => { console.log('User disconnected:', userId, reason); - socket.on('disconnect', async () => { - console.log('User disconnected:', userId); - clearHeartbeatTimer(socket.id); unregisterDeviceSocket(socket.id); @@ -246,13 +233,12 @@ io.on('connection', async (socket: AuthSocket) => { const gatewaySub = getGatewaySubscriber(appRedis); gatewaySub.removeDevice(deviceId).catch(() => {}); } + unregisterForBackpressure(socket); clearViolations(socket.id); - - // During a gateway restart we must NOT wipe presence — surviving - // devices re-assert via heartbeat and Redis TTLs. This satisfies - // #221: Gateway restart does not drop still-connected users to offline. + // During a gateway restart we must NOT wipe presence — surviving devices + // re-assert via heartbeat and Redis TTLs. if ( isShuttingDown || reason === 'server shutting down' || @@ -261,14 +247,18 @@ io.on('connection', async (socket: AuthSocket) => { return; } - - if (appRedis) { + const deviceHasNoSockets = await unregisterPresenceSocket( + appRedis, + userId, + deviceId, + socket.id, + ); + await cleanupStaleSockets(io, appRedis, userId); - await cleanupStaleSockets(io, appRedis, userId, socket.id); - const fullyOffline = await setOffline(appRedis, userId, socket.id); - - const fullyOffline = await setOffline(appRedis, userId, deviceId); + const fullyOffline = deviceHasNoSockets + ? await setOffline(appRedis, userId, deviceId) + : false; if (fullyOffline) { const user = await db.query.users.findFirst({ diff --git a/apps/backend/src/lib/validateMessagePayload.ts b/apps/backend/src/lib/validateMessagePayload.ts index cb503dd..d5982b1 100644 --- a/apps/backend/src/lib/validateMessagePayload.ts +++ b/apps/backend/src/lib/validateMessagePayload.ts @@ -16,18 +16,17 @@ export interface MessagePayload { /** MIME-like content type token, e.g. "text", "image", "file", "system" */ - contentType?: string; + contentType?: string | undefined; /** Base64-encoded ciphertext of the message body (optional for file types) */ - ciphertext?: string; + ciphertext?: string | undefined; /** Per-recipient E2EE envelopes carrying the encrypted key */ - envelopes?: Array<{ recipientDeviceId: string; ciphertext: string }>; + envelopes?: Array<{ recipientDeviceId: string; ciphertext: string }> | undefined; /** UUID referencing the uploaded file (required for file/image/video/audio) */ - fileId?: string; + fileId?: string | undefined; } export type MessagePayloadValidationResult = - | { ok: true } - | { ok: false; code: 400 | 403; message: string }; + { ok: true } | { ok: false; code: 400 | 403; message: string }; /** All content types clients are allowed to send */ const ALLOWED_CONTENT_TYPES = new Set(['text', 'file', 'image', 'video', 'audio'] as const); diff --git a/apps/backend/src/routes/treasury.ts b/apps/backend/src/routes/treasury.ts index f2f9e33..9a9363f 100644 --- a/apps/backend/src/routes/treasury.ts +++ b/apps/backend/src/routes/treasury.ts @@ -1,8 +1,5 @@ import { Router, type IRouter } from 'express'; - - - import { z } from 'zod'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; diff --git a/apps/backend/src/services/heartbeat.ts b/apps/backend/src/services/heartbeat.ts index 13cdca3..a786c4c 100644 --- a/apps/backend/src/services/heartbeat.ts +++ b/apps/backend/src/services/heartbeat.ts @@ -4,7 +4,12 @@ import type { AuthSocket } from '../middleware/socketAuth.js'; import { db } from '../db/index.js'; import { devices } from '../db/schema.js'; import { eq } from 'drizzle-orm'; -import { refreshPresence, markDeviceOffline } from './presence.js'; +import { + markDeviceOffline, + refreshPresence, + refreshPresenceSocket, + unregisterPresenceSocket, +} from './presence.js'; const HEARTBEAT_TIMEOUT_MS = 90_000; const LAST_SEEN_THROTTLE_MS = 30_000; @@ -25,17 +30,29 @@ export function startHeartbeatTimer( timers.delete(socket.id); console.log(`Heartbeat timeout for device ${deviceId} (user ${userId})`); + let fullyOffline = true; if (redis) { - await markDeviceOffline(redis, userId, deviceId); + const deviceHasNoSockets = await unregisterPresenceSocket( + redis, + userId, + deviceId, + socket.id, + ); + fullyOffline = deviceHasNoSockets + ? await markDeviceOffline(redis, userId, deviceId) + : false; } - if (socket.connected) { + if (socket.connected && fullyOffline) { for (const room of socket.rooms) { if (room !== socket.id) { io.to(room).volatile.emit('user_offline', { userId }); io.to(room).volatile.emit('presence_update', { userId, online: false }); } } + } + + if (socket.connected) { socket.disconnect(true); } }, HEARTBEAT_TIMEOUT_MS); @@ -50,6 +67,7 @@ export function startHeartbeatTimer( if (redis) { await refreshPresence(redis, userId, deviceId); + await refreshPresenceSocket(redis, userId, deviceId, socket.id); } const now = Date.now(); diff --git a/apps/backend/src/services/presence.ts b/apps/backend/src/services/presence.ts index 20a7f0b..7d1301d 100644 --- a/apps/backend/src/services/presence.ts +++ b/apps/backend/src/services/presence.ts @@ -5,9 +5,15 @@ * device also has a small per-device key with its own TTL so heartbeat timeouts * can remove that device entry without forcing the whole user offline. * - * - On connect: upsert device entry in `presence:user:{userId}` and refresh TTL - * - On heartbeat: update lastSeen and refresh the device TTL - * - On disconnect/timeout: remove that device entry; if none remain → user offline + * Socket IDs are tracked in Redis separately from device presence. Those + * mappings let a freshly booted gateway rebuild Socket.IO room membership for + * sockets that are still active on other gateway instances, without creating + * duplicate device-level presence entries. + * + * - On connect: upsert device entry, track socket mapping, refresh TTLs + * - On heartbeat: update lastSeen and refresh device/socket TTLs + * - On disconnect/timeout: remove socket mapping; remove device only when no + * live sockets remain for that device * - GET /users/:id/presence → { online: boolean } */ import type { Server } from 'socket.io'; @@ -17,6 +23,11 @@ import { db } from '../db/index.js'; import { conversationMembers } from '../db/schema.js'; const PRESENCE_TTL = 90; // seconds +const SOCKET_MAPPING_PREFIX = 'presence:sockets:'; + +type RedisWithOptionalHashRead = Redis & { + hgetall?: (key: string) => Promise>; +}; function presenceHashKey(userId: string): string { return `presence:user:${userId}`; @@ -26,6 +37,18 @@ function presenceDeviceKey(userId: string, deviceId: string): string { return `presence:user:${userId}:device:${deviceId}`; } +function presenceSocketsKey(userId: string): string { + return `${SOCKET_MAPPING_PREFIX}${userId}`; +} + +function presenceDeviceSocketsKey(userId: string, deviceId: string): string { + return `presence:device_sockets:${userId}:${deviceId}`; +} + +function presenceSocketKey(socketId: string): string { + return `presence:socket:${socketId}`; +} + /** * Register a device connection for a user. Adds or updates the device entry and * sets/refreshes the per-device TTL. @@ -47,6 +70,69 @@ export async function setOnline( return !wasOnline; } +/** + * Track the Socket.IO socket that currently represents a user/device session. + * This is intentionally separate from device-level presence so reconnecting the + * same device does not create duplicate presence entries. + */ +export async function registerPresenceSocket( + redis: Redis, + userId: string, + deviceId: string, + socketId: string, +): Promise { + const userSocketsKey = presenceSocketsKey(userId); + const deviceSocketsKey = presenceDeviceSocketsKey(userId, deviceId); + const socketKey = presenceSocketKey(socketId); + + await redis.sadd(userSocketsKey, socketId); + await redis.sadd(deviceSocketsKey, socketId); + await redis.hset(socketKey, { userId, deviceId }); + await redis.expire(userSocketsKey, PRESENCE_TTL); + await redis.expire(deviceSocketsKey, PRESENCE_TTL); + await redis.expire(socketKey, PRESENCE_TTL); +} + +/** Refresh only the socket-mapping TTLs for an already-connected socket. */ +export async function refreshPresenceSocket( + redis: Redis, + userId: string, + deviceId: string, + socketId: string, +): Promise { + await registerPresenceSocket(redis, userId, deviceId, socketId); +} + +/** + * Remove a socket mapping. Returns true when that device has no remaining + * tracked sockets, so callers may safely remove the device-level presence entry. + */ +export async function unregisterPresenceSocket( + redis: Redis, + userId: string, + deviceId: string, + socketId: string, +): Promise { + const userSocketsKey = presenceSocketsKey(userId); + const deviceSocketsKey = presenceDeviceSocketsKey(userId, deviceId); + + await redis.srem(userSocketsKey, socketId); + await redis.srem(deviceSocketsKey, socketId); + await redis.del(presenceSocketKey(socketId)); + + const remainingDeviceSockets = await redis.scard(deviceSocketsKey); + if (remainingDeviceSockets === 0) { + await redis.del(deviceSocketsKey); + } + + const remainingUserSockets = await redis.scard(userSocketsKey); + if (remainingUserSockets === 0) { + await redis.del(userSocketsKey); + } + + return remainingDeviceSockets === 0; +} + /** * Refresh the presence timestamp and TTL for a specific device (called on heartbeat). */ @@ -115,8 +201,31 @@ export async function isOnline(redis: Redis, userId: string): Promise { return count > 0; } +async function removeStaleSocketMapping( + redis: Redis, + userId: string, + socketId: string, +): Promise { + const redisWithHashRead = redis as RedisWithOptionalHashRead; + const mapping = redisWithHashRead.hgetall + ? await redisWithHashRead.hgetall(presenceSocketKey(socketId)) + : {}; + const deviceId = mapping['deviceId']; + + await redis.srem(presenceSocketsKey(userId), socketId); + if (deviceId) { + const deviceSocketsKey = presenceDeviceSocketsKey(userId, deviceId); + await redis.srem(deviceSocketsKey, socketId); + const remainingDeviceSockets = await redis.scard(deviceSocketsKey); + if (remainingDeviceSockets === 0) { + await redis.del(deviceSocketsKey); + } + } + await redis.del(presenceSocketKey(socketId)); +} + /** - * Remove any socket IDs in the user's presence set that are no longer + * Remove any socket IDs in the user's Redis socket mapping that are no longer * connected anywhere in the Socket.IO cluster. */ export async function cleanupStaleSockets( @@ -125,7 +234,7 @@ export async function cleanupStaleSockets( userId: string, ignoredSocketId?: string, ): Promise { - const key = presenceKey(userId); + const key = presenceSocketsKey(userId); const socketIds = await redis.smembers(key); if (socketIds.length === 0) return; @@ -135,7 +244,7 @@ export async function cleanupStaleSockets( try { const sockets = await io.in(sid).fetchSockets(); if (sockets.length === 0) { - await redis.srem(key, sid); + await removeStaleSocketMapping(redis, userId, sid); } } catch (err) { console.warn(`[presence] Failed to check socket status for ${sid}:`, err); @@ -153,25 +262,34 @@ export async function cleanupStaleSockets( * Rebuild room subscriptions from active Redis socket mappings on gateway boot. */ export async function reconcileBoot(io: Server, redis: Redis): Promise { - let presenceKeys: string[]; + let presenceSocketKeys: string[]; try { let cursor = '0'; - presenceKeys = []; + presenceSocketKeys = []; do { - const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', 'presence:*', 'COUNT', 100); + const [nextCursor, keys] = await redis.scan( + cursor, + 'MATCH', + `${SOCKET_MAPPING_PREFIX}*`, + 'COUNT', + 100, + ); cursor = nextCursor; - presenceKeys.push(...keys); + presenceSocketKeys.push(...keys); } while (cursor !== '0'); } catch { - presenceKeys = await redis.keys('presence:*'); + presenceSocketKeys = await redis.keys(`${SOCKET_MAPPING_PREFIX}*`); } - for (const key of presenceKeys) { - const userId = key.slice('presence:'.length); + for (const key of presenceSocketKeys) { + const userId = key.slice(SOCKET_MAPPING_PREFIX.length); if (!userId) continue; const socketIds = await redis.smembers(key); - if (socketIds.length === 0) continue; + if (socketIds.length === 0) { + await redis.del(key); + continue; + } try { const memberships = await db.query.conversationMembers.findMany({ @@ -179,10 +297,23 @@ export async function reconcileBoot(io: Server, redis: Redis): Promise { columns: { conversationId: true }, }); - for (const socketId of socketIds) { - for (const m of memberships) { - io.in(socketId).socketsJoin(m.conversationId); - } + await Promise.all( + socketIds.map(async (socketId) => { + const sockets = await io.in(socketId).fetchSockets(); + if (sockets.length === 0) { + await removeStaleSocketMapping(redis, userId, socketId); + return; + } + + for (const m of memberships) { + io.in(socketId).socketsJoin(m.conversationId); + } + }), + ); + + const remaining = await redis.scard(key); + if (remaining === 0) { + await redis.del(key); } } catch (err) { console.warn(`[presence] Failed to rebuild subscriptions for ${userId}:`, err); diff --git a/apps/backend/src/socket/messaging.ts b/apps/backend/src/socket/messaging.ts index e9e99e6..f5d6df8 100644 --- a/apps/backend/src/socket/messaging.ts +++ b/apps/backend/src/socket/messaging.ts @@ -66,13 +66,22 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void // ── send_message ─────────────────────────────────────────────────────────── dispatcher.register('send_message', async (payload) => { - const { conversationId, messageId, content, contentType, ciphertext, envelopes } = payload as { + const { + conversationId, + messageId, + content, + contentType, + ciphertext, + envelopes, + fileId: payloadFileId, + } = payload as { conversationId: string; messageId?: string; content?: string; contentType?: string; ciphertext?: string; envelopes?: Array<{ recipientDeviceId: string; ciphertext: string }>; + fileId?: string; }; const deviceId = socket.auth!.deviceId; @@ -103,7 +112,7 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void contentType, ciphertext: effectiveCiphertext, envelopes, - fileId, + fileId: payloadFileId, }); if (!validation.ok) { socket.emit('error', { @@ -136,7 +145,7 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void return; } - let fileId: string | undefined; + let fileId: string | undefined = payloadFileId; const resolvedContentType = contentType || 'text/plain'; if (FILE_CONTENT_TYPES.has(resolvedContentType)) { const [fileRow] = await db @@ -144,7 +153,7 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void .values({ storageKey: messageId }) .onConflictDoUpdate({ target: files.storageKey, set: { storageKey: messageId } }) .returning({ id: files.id }); - fileId = fileRow?.id; + fileId = fileRow?.id ?? payloadFileId; } const [message] = await db @@ -197,10 +206,13 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void } } - if (message) { - socket.emit('message_ack', { messageId, sequenceNumber: message.sequenceNumber }); + if (!message) { + socket.emit('error', { event: 'send_message', message: 'Failed to persist message' }); + return; } + socket.emit('message_ack', { messageId, sequenceNumber: message.sequenceNumber }); + await deliverMessage(io, message, conversationId); const members = await db.query.conversationMembers.findMany({ diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 5e891cf..30a7faa 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,7 +1,5 @@ import type { NextConfig } from 'next'; -const nextConfig: NextConfig = { - /* config options here */ -}; +const nextConfig: NextConfig = {/* config options here */}; export default nextConfig; diff --git a/apps/web/src/app/app/layout.tsx b/apps/web/src/app/app/layout.tsx index 09b35ce..df014bf 100644 --- a/apps/web/src/app/app/layout.tsx +++ b/apps/web/src/app/app/layout.tsx @@ -1,10 +1,10 @@ 'use client'; -import React, { useEffect, useState } from "react"; -import Link from "next/link"; -import { usePathname, useRouter } from "next/navigation"; -import { useWallet } from "@/contexts/WalletContext"; -import { PushPermissionPrompt } from "@/components/PushPermissionPrompt"; +import React, { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { usePathname, useRouter } from 'next/navigation'; +import { useWallet } from '@/contexts/WalletContext'; +import { PushPermissionPrompt } from '@/components/PushPermissionPrompt'; // Custom premium SVG Icons to avoid dependency weight const LogoIcon = () => ( @@ -159,20 +159,20 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { // Listen for sw:sync messages from the service worker (notification click). // Navigate to the conversation so the page re-fetches fresh data. useEffect(() => { - if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return; + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return; function onSwMessage(event: MessageEvent<{ type: string; conversationId?: string | null }>) { - if (event.data?.type !== "sw:sync") return; + if (event.data?.type !== 'sw:sync') return; const { conversationId } = event.data; if (conversationId) { router.push(`/app/conversations/${conversationId}`); } else { - router.push("/app/messages"); + router.push('/app/messages'); } } - navigator.serviceWorker.addEventListener("message", onSwMessage); - return () => navigator.serviceWorker.removeEventListener("message", onSwMessage); + navigator.serviceWorker.addEventListener('message', onSwMessage); + return () => navigator.serviceWorker.removeEventListener('message', onSwMessage); }, [router]); const handleWalletAction = async () => { diff --git a/apps/web/src/components/PushPermissionPrompt.tsx b/apps/web/src/components/PushPermissionPrompt.tsx index 87e1b8d..9cfa437 100644 --- a/apps/web/src/components/PushPermissionPrompt.tsx +++ b/apps/web/src/components/PushPermissionPrompt.tsx @@ -1,10 +1,10 @@ -"use client"; +'use client'; -import { useEffect, useState } from "react"; -import { useAuth } from "@/components/auth/useAuth"; -import { usePushSubscription } from "@/hooks/usePushSubscription"; +import { useEffect, useState } from 'react'; +import { useAuth } from '@/components/auth/useAuth'; +import { usePushSubscription } from '@/hooks/usePushSubscription'; -const DISMISSED_KEY = "clicked.push.dismissed"; +const DISMISSED_KEY = 'clicked.push.dismissed'; // Shown contextually inside the authenticated app shell, not on first page load. // Appears 5 seconds after the component mounts (i.e. after the user navigates @@ -16,8 +16,8 @@ export function PushPermissionPrompt() { useEffect(() => { if (!token) return; - if (permission !== "default") return; - if (typeof sessionStorage !== "undefined" && sessionStorage.getItem(DISMISSED_KEY)) return; + if (permission !== 'default') return; + if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(DISMISSED_KEY)) return; const timer = setTimeout(() => setVisible(true), 5000); return () => clearTimeout(timer); @@ -25,7 +25,7 @@ export function PushPermissionPrompt() { function dismiss() { setVisible(false); - sessionStorage.setItem(DISMISSED_KEY, "1"); + sessionStorage.setItem(DISMISSED_KEY, '1'); } async function enable() { @@ -34,7 +34,7 @@ export function PushPermissionPrompt() { } // Hide when not visible, permission already decided, or already subscribed. - if (!visible || permission !== "default" || subscribed) return null; + if (!visible || permission !== 'default' || subscribed) return null; return (
{ - const padding = "=".repeat((4 - (base64url.length % 4)) % 4); - const base64 = (base64url + padding).replace(/-/g, "+").replace(/_/g, "/"); + const padding = '='.repeat((4 - (base64url.length % 4)) % 4); + const base64 = (base64url + padding).replace(/-/g, '+').replace(/_/g, '/'); const raw = atob(base64); const bytes = new Uint8Array(new ArrayBuffer(raw.length)); for (let i = 0; i < raw.length; i++) { @@ -21,9 +21,9 @@ function vapidKeyToUint8Array(base64url: string): Uint8Array { async function postSubscription(sub: PushSubscription, token: string): Promise { const json = sub.toJSON(); await fetch(`${API_BASE_URL}/push/subscriptions`, { - method: "POST", + method: 'POST', headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify({ @@ -45,25 +45,25 @@ export type PushSubscriptionState = { export function usePushSubscription(token: string | null): PushSubscriptionState { const [registration, setRegistration] = useState(null); const [permission, setPermission] = useState(() => { - if (typeof window !== "undefined" && "Notification" in window) { + if (typeof window !== 'undefined' && 'Notification' in window) { return Notification.permission; } - return "default"; + return 'default'; }); const [subscribed, setSubscribed] = useState(false); // Register the service worker once on mount. useEffect(() => { if ( - typeof window === "undefined" || - !("serviceWorker" in navigator) || - !("PushManager" in window) + typeof window === 'undefined' || + !('serviceWorker' in navigator) || + !('PushManager' in window) ) { return; } let active = true; - navigator.serviceWorker.register("/sw.js").then((reg) => { + navigator.serviceWorker.register('/sw.js').then((reg) => { if (active) setRegistration(reg); }); @@ -75,7 +75,7 @@ export function usePushSubscription(token: string | null): PushSubscriptionState // Re-use an existing subscription if one already exists. useEffect(() => { if (!registration || !token || !VAPID_PUBLIC_KEY) return; - if (Notification.permission !== "granted") return; + if (Notification.permission !== 'granted') return; let active = true; registration.pushManager.getSubscription().then((existing) => { @@ -95,7 +95,7 @@ export function usePushSubscription(token: string | null): PushSubscriptionState const result = await Notification.requestPermission(); setPermission(result); - if (result !== "granted") return; + if (result !== 'granted') return; // Reuse an existing subscription to avoid double-posting. let sub = await registration.pushManager.getSubscription(); From 559f7b25d4c8f19b6eca0b0996779692b591c3b6 Mon Sep 17 00:00:00 2001 From: "Huncho.Dev" Date: Tue, 30 Jun 2026 20:17:02 +0000 Subject: [PATCH 4/4] style: format message payload validator --- apps/backend/src/lib/validateMessagePayload.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/lib/validateMessagePayload.ts b/apps/backend/src/lib/validateMessagePayload.ts index d5982b1..9ac37c4 100644 --- a/apps/backend/src/lib/validateMessagePayload.ts +++ b/apps/backend/src/lib/validateMessagePayload.ts @@ -26,7 +26,8 @@ export interface MessagePayload { } export type MessagePayloadValidationResult = - { ok: true } | { ok: false; code: 400 | 403; message: string }; + | { ok: true } + | { ok: false; code: 400 | 403; message: string }; /** All content types clients are allowed to send */ const ALLOWED_CONTENT_TYPES = new Set(['text', 'file', 'image', 'video', 'audio'] as const);