diff --git a/backend/docs/REALTIME.md b/backend/docs/REALTIME.md new file mode 100644 index 00000000..9166df2f --- /dev/null +++ b/backend/docs/REALTIME.md @@ -0,0 +1,94 @@ +# Realtime Module + +Socket.IO gateway that pushes tip and notification events to connected +clients. See `src/realtime/`. + +## Components + +| File | Purpose | +|------|---------| +| `types.ts` | Shared typed contract: event names + payloads, `SocketData` | +| `auth.ts` | Handshake middleware — verifies the same JWT the REST API issues | +| `rateLimit.ts` | Per-IP connection throttling and per-socket event throttling | +| `gateway.ts` | Server init, room management, `emitTipCreated` / `emitNotificationCreated` | + +## Connecting + +Clients authenticate on the handshake, not via a separate event: + +```ts +import { io } from 'socket.io-client'; + +const socket = io(API_URL, { + auth: { token: accessToken }, // the same access token used for REST calls + transports: ['websocket'], +}); + +socket.on('connected', ({ userId }) => { /* handshake accepted */ }); +socket.on('error', ({ code, message }) => { /* FORBIDDEN, RATE_LIMITED, ... */ }); +``` + +A connection with a missing or invalid token is rejected before `connection` +fires — the client only sees `connect_error`. + +## Event contract + +The full typed contract lives in `types.ts` (`ServerToClientEvents`, +`ClientToServerEvents`). Summary: + +- **Client → server:** `subscribe:creator`, `subscribe:notifications`, + `unsubscribe:creator`, `unsubscribe:notifications` +- **Server → client:** `connected`, `error`, `tip.created`, + `notification.created` + +Subscribing to another user's `notifications` room is rejected with an +`error` event (`code: 'FORBIDDEN'`) — a socket may only subscribe to its own +`user:` room. + +## Heartbeat + +The server pings each client on a fixed interval and disconnects it if no +pong is received within the timeout (configured in `gateway.ts`): + +- `pingInterval`: 25s — how often the server probes the connection +- `pingTimeout`: 20s — how long it waits for a response before dropping it + +These are handled by Socket.IO's engine (no application code needed) and +detect dead connections (e.g. a laptop going to sleep or a dropped Wi-Fi +network) faster than waiting on a TCP timeout. + +## Reconnection + +`socket.io-client` reconnects automatically by default (exponential backoff, +capped, with jitter). Important behavior to build clients against: + +- **Rooms are not restored automatically.** On `reconnect`, re-issue + `subscribe:creator` / `subscribe:notifications` for anything the client + still cares about — the server has no memory of a socket's prior rooms + once it disconnects. +- **The auth token is re-sent on every reconnect attempt** (it's read from + the `auth` option passed to `io(...)`, not cached per-connection). If the + token expires while offline, refresh it before the client comes back + online so reconnection doesn't loop into `connect_error`. +- Recommended client wiring: + +```ts +socket.on('reconnect', () => { + socket.emit('subscribe:notifications', currentUserId); + socket.emit('subscribe:creator', watchedCreatorAddress); +}); +``` + +## Rate limiting + +Two independent limiters, both in-memory per server process (see +`rateLimit.ts`): + +- **Connections:** 20 new connections per IP per 60s window, enforced as a + handshake middleware before auth runs. +- **Events:** 30 client→server events per socket per 10s window, enforced at + the top of every event handler. + +Exceeding the connection limit rejects the handshake (`connect_error`). +Exceeding the event limit emits `error` (`code: 'RATE_LIMITED'`) and drops +that event; the socket stays connected. diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 26eef106..098b77db 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -27,32 +27,21 @@ model User { scopes String[] @default([]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - apiKeys ApiKey[] - refreshTokens RefreshToken[] - goals Goal[] - sentTips Tip[] @relation("SentTips") - receivedTips Tip[] @relation("ReceivedTips") - leaderboardSnapshots LeaderboardSnapshot[] - streak Streak? - notifications Notification[] - - tipperSubscriptions Subscription[] @relation("SubscriptionTipper") - creatorSubscriptions Subscription[] @relation("SubscriptionCreator") - /// Soft-delete marker: non-null means the record is logically deleted. - deletedAt DateTime? apiKeys ApiKey[] refreshTokens RefreshToken[] goals Goal[] sentTips Tip[] @relation("SentTips") receivedTips Tip[] @relation("ReceivedTips") + leaderboardSnapshots LeaderboardSnapshot[] + streak Streak? + notifications Notification[] tipperSubscriptions Subscription[] @relation("SubscriptionTipper") creatorSubscriptions Subscription[] @relation("SubscriptionCreator") creditScore CreditScore? creditScoreHistory CreditScoreHistory[] withdrawals Withdrawal[] - notifications Notification[] - leaderboardSnapshots LeaderboardSnapshot[] - streak Streak? + /// Soft-delete marker: non-null means the record is logically deleted. + deletedAt DateTime? @@index([createdAt]) } @@ -200,13 +189,6 @@ model XAccount { updatedAt DateTime @updatedAt } -/// Status of a tip transaction. -enum TipStatus { - CONFIRMED - PENDING - REFUNDED -} - /// Leaderboard period tracked by snapshots. enum Period { WEEKLY diff --git a/backend/src/app.ts b/backend/src/app.ts index 53b81c1f..c6af51e3 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -7,14 +7,6 @@ import { env } from './config/env.js'; import { errorHandler, notFoundHandler, -} from "./common/middleware/errorHandler.js"; -import { logger } from "./common/utils/logger.js"; -import { openApiDocument } from "./docs/openapi.js"; -import { authRouter } from "./modules/auth/auth.routes.js"; -import { profilesRouter } from "./modules/profiles/profiles.routes.js"; -import { creditRouter } from "./modules/credit/credit.routes.js"; -import { leaderboardRouter } from "./modules/leaderboard/leaderboard.routes.js"; -import { xRouter } from "./modules/x/x.routes.js"; } from './common/middleware/errorHandler.js'; import { logger } from './common/utils/logger.js'; import { openApiDocument } from './docs/openapi.js'; diff --git a/backend/src/common/errors/AppError.ts b/backend/src/common/errors/AppError.ts index fb874b60..66be7fc1 100644 --- a/backend/src/common/errors/AppError.ts +++ b/backend/src/common/errors/AppError.ts @@ -40,12 +40,6 @@ export class ConflictError extends AppError { super(409, message, "CONFLICT"); } } -export class ServiceUnavailableError extends AppError { - constructor(message = "Service unavailable") { - super(503, message, "SERVICE_UNAVAILABLE"); - } -} - export class BadGatewayError extends AppError { constructor(message = 'Bad gateway', details?: unknown) { super(502, message, 'BAD_GATEWAY', details); diff --git a/backend/src/realtime/auth.ts b/backend/src/realtime/auth.ts index cafd8be6..fbe44466 100644 --- a/backend/src/realtime/auth.ts +++ b/backend/src/realtime/auth.ts @@ -1,26 +1,27 @@ -import type { Socket } from 'socket.io'; -import jwt from 'jsonwebtoken'; -import { config } from '../config/index.js'; +import type { Socket, ExtendedError } from 'socket.io'; +import { verifyAccessToken } from '../modules/auth/auth.service.js'; import { logger } from '../common/utils/logger.js'; -import type { AuthUser } from '../modules/auth/auth.types.js'; +import type { + ClientToServerEvents, + ServerToClientEvents, + InterServerEvents, + SocketData, +} from './types.js'; -interface JwtPayload { - sub: string; - stellarAddress: string; -} - -export interface AuthenticatedSocket extends Socket { - authUser?: AuthUser; -} - -declare module 'socket.io' { - interface Socket { - authUser?: AuthUser; - } -} +type AuthSocket = Socket; -export function socketAuth(socket: AuthenticatedSocket, next: (err?: Error) => void): void { - const token = socket.handshake.auth?.token as string | undefined; +/** + * Socket.IO handshake middleware: authenticates a connecting socket using the + * same JWT access token issued by the REST auth module. + * + * The client must send the token as `socket.handshake.auth.token`, e.g.: + * io(url, { auth: { token: accessToken } }) + * + * On success, the decoded payload is attached to `socket.data.auth`. + * On failure, the connection is rejected before `connection` fires. + */ +export function socketAuth(socket: AuthSocket, next: (err?: ExtendedError) => void): void { + const token = socket.handshake.auth?.['token'] as string | undefined; if (!token) { logger.warn({ socketId: socket.id }, 'Socket connection rejected: no token'); @@ -29,16 +30,14 @@ export function socketAuth(socket: AuthenticatedSocket, next: (err?: Error) => v } try { - const payload = jwt.verify(token, config.auth.jwtSecret) as JwtPayload; - socket.authUser = { - id: payload.sub, - stellarAddress: payload.stellarAddress, - username: null, - }; - logger.debug({ socketId: socket.id, userId: payload.sub }, 'Socket authenticated'); + socket.data.auth = verifyAccessToken(token); + logger.debug( + { socketId: socket.id, userId: socket.data.auth.userId }, + 'Socket authenticated', + ); next(); - } catch (err) { - logger.warn({ socketId: socket.id, err }, 'Socket connection rejected: invalid token'); + } catch { + logger.warn({ socketId: socket.id }, 'Socket connection rejected: invalid token'); next(new Error('Invalid or expired token')); } } diff --git a/backend/src/realtime/gateway.ts b/backend/src/realtime/gateway.ts index ddbd3f50..cdfbfcd4 100644 --- a/backend/src/realtime/gateway.ts +++ b/backend/src/realtime/gateway.ts @@ -2,46 +2,66 @@ import type { Server as HttpServer } from 'node:http'; import { Server as SocketIOServer } from 'socket.io'; import { env } from '../config/env.js'; import { logger } from '../common/utils/logger.js'; +import { registerClosable } from '../common/utils/lifecycle.js'; import { socketAuth } from './auth.js'; +import { connectionRateLimit, eventLimiter, guardEventRate } from './rateLimit.js'; import type { ServerToClientEvents, ClientToServerEvents, + InterServerEvents, + SocketData, NotificationPayload, } from './types.js'; import type { TipResponseDto } from '../modules/tips/tips.dto.js'; -let io: SocketIOServer | null = null; +export type RealtimeServer = SocketIOServer< + ClientToServerEvents, + ServerToClientEvents, + InterServerEvents, + SocketData +>; + +let io: RealtimeServer | null = null; + +/** + * Heartbeat tuning (see docs/REALTIME.md): how often the server pings each + * client, and how long it waits for a pong before considering it disconnected. + */ +const HEARTBEAT_PING_INTERVAL_MS = 25_000; +const HEARTBEAT_PING_TIMEOUT_MS = 20_000; -export function initRealtime(httpServer: HttpServer): SocketIOServer { - io = new SocketIOServer(httpServer, { - cors: { - origin: env.CORS_ORIGIN.split(','), - methods: ['GET', 'POST'], +export function initRealtime(httpServer: HttpServer): RealtimeServer { + io = new SocketIOServer( + httpServer, + { + cors: { + origin: env.CORS_ORIGIN.split(','), + methods: ['GET', 'POST'], + }, + pingInterval: HEARTBEAT_PING_INTERVAL_MS, + pingTimeout: HEARTBEAT_PING_TIMEOUT_MS, }, - }); + ); + io.use(connectionRateLimit); io.use(socketAuth); io.on('connection', (socket) => { - logger.info({ socketId: socket.id, userId: socket.authUser?.id }, 'Client connected'); + const { userId } = socket.data.auth; + logger.info({ socketId: socket.id, userId }, 'Client connected'); + socket.emit('connected', { userId }); socket.on('subscribe:creator', (creatorAddress: string) => { - if (!socket.authUser) { - (socket as any).emit('error', { message: 'Not authenticated' }); - return; - } + if (!guardEventRate(socket)) return; const room = `creator:${creatorAddress}`; void socket.join(room); logger.debug({ socketId: socket.id, room }, 'Subscribed to creator room'); }); socket.on('subscribe:notifications', (userId: string) => { - if (!socket.authUser) { - (socket as any).emit('error', { message: 'Not authenticated' }); - return; - } - if (socket.authUser.id !== userId) { - (socket as any).emit('error', { message: 'Forbidden' }); + if (!guardEventRate(socket)) return; + if (socket.data.auth.userId !== userId) { + socket.emit('error', { code: 'FORBIDDEN', message: 'Cannot subscribe to another user' }); return; } const room = `user:${userId}`; @@ -50,22 +70,35 @@ export function initRealtime(httpServer: HttpServer): SocketIOServer { + if (!guardEventRate(socket)) return; const room = `creator:${creatorAddress}`; void socket.leave(room); logger.debug({ socketId: socket.id, room }, 'Unsubscribed from creator room'); }); socket.on('unsubscribe:notifications', (userId: string) => { + if (!guardEventRate(socket)) return; const room = `user:${userId}`; void socket.leave(room); logger.debug({ socketId: socket.id, room }, 'Unsubscribed from notifications room'); }); - socket.on('disconnect', () => { - logger.info({ socketId: socket.id }, 'Client disconnected'); + socket.on('disconnect', (reason) => { + logger.info({ socketId: socket.id, reason }, 'Client disconnected'); }); }); + const sweepInterval = setInterval(() => eventLimiter.sweep(), 60_000); + sweepInterval.unref(); + + registerClosable({ + name: 'Socket.IO', + close: async () => { + clearInterval(sweepInterval); + await new Promise((resolve) => io?.close(() => resolve())); + }, + }); + logger.info('Realtime gateway initialized'); return io; } @@ -79,9 +112,12 @@ export function emitTipCreated(tip: TipResponseDto): void { export function emitNotificationCreated(notification: NotificationPayload): void { if (!io) return; io.to(`user:${notification.userId}`).emit('notification.created', notification); - logger.debug({ notificationId: notification.id, room: `user:${notification.userId}` }, 'Emitted notification.created'); + logger.debug( + { notificationId: notification.id, room: `user:${notification.userId}` }, + 'Emitted notification.created', + ); } -export function getIO(): SocketIOServer | null { +export function getIO(): RealtimeServer | null { return io; } diff --git a/backend/src/realtime/index.ts b/backend/src/realtime/index.ts index 3b09a851..ace4327f 100644 --- a/backend/src/realtime/index.ts +++ b/backend/src/realtime/index.ts @@ -1,2 +1,8 @@ -export { initRealtime, emitTipCreated, emitNotificationCreated, getIO } from "./gateway.js"; -export type { ServerToClientEvents, ClientToServerEvents, NotificationPayload } from "./types.js"; +export { initRealtime, emitTipCreated, emitNotificationCreated, getIO } from './gateway.js'; +export type { + ServerToClientEvents, + ClientToServerEvents, + InterServerEvents, + SocketData, + NotificationPayload, +} from './types.js'; diff --git a/backend/src/realtime/rateLimit.ts b/backend/src/realtime/rateLimit.ts new file mode 100644 index 00000000..9dfa88cf --- /dev/null +++ b/backend/src/realtime/rateLimit.ts @@ -0,0 +1,77 @@ +import type { Socket } from 'socket.io'; +import { logger } from '../common/utils/logger.js'; +import type { ServerToClientEvents } from './types.js'; + +interface Window { + count: number; + resetAt: number; +} + +/** Fixed-window rate limiter keyed by an arbitrary string (IP, socket id, ...). */ +export class SlidingWindowLimiter { + private readonly hits = new Map(); + + constructor( + private readonly max: number, + private readonly windowMs: number, + ) {} + + /** Records one hit for `key`; returns false once `max` is exceeded within the window. */ + consume(key: string): boolean { + const now = Date.now(); + const entry = this.hits.get(key); + + if (!entry || now >= entry.resetAt) { + this.hits.set(key, { count: 1, resetAt: now + this.windowMs }); + return true; + } + + if (entry.count >= this.max) { + return false; + } + + entry.count += 1; + return true; + } + + /** Drops expired entries so the map doesn't grow unbounded. */ + sweep(): void { + const now = Date.now(); + for (const [key, entry] of this.hits) { + if (now >= entry.resetAt) this.hits.delete(key); + } + } +} + +/** Throttles new socket connections per client IP. */ +export const connectionLimiter = new SlidingWindowLimiter(20, 60_000); + +/** Throttles client->server events per connected socket. */ +export const eventLimiter = new SlidingWindowLimiter(30, 10_000); + +/** Socket.IO handshake middleware rejecting connections once the per-IP limit is hit. */ +export function connectionRateLimit(socket: Socket, next: (err?: Error) => void): void { + const ip = socket.handshake.address; + + if (!connectionLimiter.consume(ip)) { + logger.warn({ ip, socketId: socket.id }, 'Socket connection rate limited'); + next(new Error('Too many connection attempts, please try again later')); + return; + } + + next(); +} + +/** + * Call at the top of every client event handler. Returns false (and notifies + * the client) once the socket has exceeded its per-window event budget. + */ +export function guardEventRate(socket: Socket): boolean { + if (!eventLimiter.consume(socket.id)) { + logger.warn({ socketId: socket.id }, 'Socket event rate limited'); + socket.emit('error', { code: 'RATE_LIMITED', message: 'Too many requests, slow down' }); + return false; + } + + return true; +} diff --git a/backend/src/realtime/realtime.auth.ts b/backend/src/realtime/realtime.auth.ts deleted file mode 100644 index 879d933a..00000000 --- a/backend/src/realtime/realtime.auth.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Socket, ExtendedError } from "socket.io"; -import { verifyAccessToken } from "@/modules/auth/auth.service.js"; -import type { SocketData } from "./realtime.types.js"; - -/** - * Socket.IO middleware (#947): authenticates a connecting socket using the - * same JWT access token issued by the REST auth module. - * - * The client must send the token as `socket.handshake.auth.token`, e.g.: - * io(url, { auth: { token: accessToken } }) - * - * On success, the decoded payload is attached to `socket.data.auth`. - * On failure, the connection is rejected before `connection` fires. - */ -export function socketAuthMiddleware( - socket: Socket, - next: (err?: ExtendedError) => void, -): void { - const token = socket.handshake.auth?.["token"] as string | undefined; - - if (!token) { - next(new Error("Missing authentication token")); - return; - } - - try { - socket.data.auth = verifyAccessToken(token); - next(); - } catch { - next(new Error("Invalid or expired authentication token")); - } -} diff --git a/backend/src/realtime/realtime.gateway.ts b/backend/src/realtime/realtime.gateway.ts deleted file mode 100644 index f779cfa8..00000000 --- a/backend/src/realtime/realtime.gateway.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { Server as HttpServer } from "node:http"; -import { Server } from "socket.io"; -import { env } from "@/config/env.js"; -import { logger } from "@/common/utils/logger.js"; -import { registerClosable } from "@/common/utils/lifecycle.js"; -import { socketAuthMiddleware } from "./realtime.auth.js"; -import type { - ClientToServerEvents, - ServerToClientEvents, - InterServerEvents, - SocketData, -} from "./realtime.types.js"; - -export type RealtimeServer = Server< - ClientToServerEvents, - ServerToClientEvents, - InterServerEvents, - SocketData ->; - -/** - * Realtime gateway (#946): attaches Socket.IO to the given HTTP server. - * - * Auth (#947) is enforced via a handshake middleware before `connection` - * fires, so every socket reaching the handler below is authenticated. - */ -export function initRealtime(httpServer: HttpServer): RealtimeServer { - const io: RealtimeServer = new Server(httpServer, { - cors: { origin: env.CORS_ORIGIN.split(","), credentials: true }, - }); - - io.use(socketAuthMiddleware); - - io.on("connection", (socket) => { - const { userId } = socket.data.auth; - logger.info({ userId, socketId: socket.id }, "Socket connected"); - - socket.emit("connected", { userId }); - - socket.on("disconnect", (reason) => { - logger.info({ userId, socketId: socket.id, reason }, "Socket disconnected"); - }); - }); - - registerClosable({ - name: "Socket.IO", - close: async () => { - await io.close(); - }, - }); - - return io; -} diff --git a/backend/src/realtime/realtime.test.ts b/backend/src/realtime/realtime.test.ts index 4d5b7922..50183977 100644 --- a/backend/src/realtime/realtime.test.ts +++ b/backend/src/realtime/realtime.test.ts @@ -1,88 +1,96 @@ -/** - * Tests for #946 (Socket.IO gateway init) and #947 (JWT socket auth handshake). - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { createServer } from "node:http"; -import type { AddressInfo } from "node:net"; -import { io as ioClient, type Socket as ClientSocket } from "socket.io-client"; -import jwt from "jsonwebtoken"; - -vi.mock("@/config/env.js", () => ({ - env: { - NODE_ENV: "test", - PORT: 4000, - CORS_ORIGIN: "http://localhost:5173", - JWT_SECRET: "test-secret-key-for-vitest", - JWT_EXPIRES_IN: "15m", - LOG_LEVEL: "silent", - }, -})); - -vi.mock("@/db/prisma.js", () => ({ - prisma: {}, -})); - -import { initRealtime } from "./realtime.gateway.js"; -import { socketAuthMiddleware } from "./realtime.auth.js"; - -const TEST_SECRET = "test-secret-key-for-vitest"; - -function makeToken(payload: object): string { - return jwt.sign(payload, TEST_SECRET, { expiresIn: "15m" }); +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import jwt from 'jsonwebtoken'; +import { io as ioClient, type Socket as ClientSocket } from 'socket.io-client'; +import { env } from '../config/env.js'; +import { initRealtime } from './gateway.js'; +import { socketAuth } from './auth.js'; +import { connectionLimiter, eventLimiter, SlidingWindowLimiter } from './rateLimit.js'; + +function makeToken(overrides: Partial> = {}): string { + return jwt.sign( + { userId: 'user-1', stellarAddress: 'GABC', role: 'user', scopes: [], ...overrides }, + env.JWT_SECRET, + { expiresIn: '1h' }, + ); } -// ── #947 socketAuthMiddleware (unit) ────────────────────────────────────────── - -describe("socketAuthMiddleware (issue #947)", () => { - it("rejects a connection with no token", () => { +describe('socketAuth', () => { + it('rejects a connection with no token', () => { const next = vi.fn(); - const socket = { handshake: { auth: {} }, data: {} } as never; + const socket = { id: 's1', handshake: { auth: {} }, data: {} } as never; - socketAuthMiddleware(socket, next); + socketAuth(socket, next); expect(next).toHaveBeenCalledWith(expect.any(Error)); }); - it("rejects a connection with an invalid token", () => { + it('rejects a connection with an invalid token', () => { const next = vi.fn(); const socket = { - handshake: { auth: { token: "not-a-real-token" } }, + id: 's2', + handshake: { auth: { token: 'not-a-real-token' } }, data: {}, } as never; - socketAuthMiddleware(socket, next); + socketAuth(socket, next); expect(next).toHaveBeenCalledWith(expect.any(Error)); }); - it("accepts a valid token and attaches the payload to socket.data.auth", () => { + it('accepts a valid token and attaches the payload to socket.data.auth', () => { const next = vi.fn(); - const token = makeToken({ - userId: "user_01", - stellarAddress: "GABC", - role: "user", - scopes: [], - }); - const socket = { handshake: { auth: { token } }, data: {} } as never as { + const token = makeToken({ userId: 'user-42' }); + const socket = { id: 's3', handshake: { auth: { token } }, data: {} } as never as { data: { auth?: { userId: string } }; }; - socketAuthMiddleware(socket as never, next); + socketAuth(socket as never, next); expect(next).toHaveBeenCalledWith(); - expect(socket.data.auth?.userId).toBe("user_01"); + expect(socket.data.auth?.userId).toBe('user-42'); }); }); -// ── #946 initRealtime (integration) ─────────────────────────────────────────── +describe('SlidingWindowLimiter', () => { + it('allows up to `max` hits per window and rejects the next one', () => { + const limiter = new SlidingWindowLimiter(2, 10_000); + + expect(limiter.consume('a')).toBe(true); + expect(limiter.consume('a')).toBe(true); + expect(limiter.consume('a')).toBe(false); + }); + + it('tracks separate keys independently', () => { + const limiter = new SlidingWindowLimiter(1, 10_000); + + expect(limiter.consume('a')).toBe(true); + expect(limiter.consume('b')).toBe(true); + }); + + it('resets once the window elapses', () => { + vi.useFakeTimers(); + const limiter = new SlidingWindowLimiter(1, 1_000); -describe("initRealtime (issue #946)", () => { + expect(limiter.consume('a')).toBe(true); + expect(limiter.consume('a')).toBe(false); + + vi.advanceTimersByTime(1_001); + expect(limiter.consume('a')).toBe(true); + + vi.useRealTimers(); + }); +}); + +describe('initRealtime', () => { let httpServer: ReturnType; let port: number; let clientSocket: ClientSocket; beforeEach(async () => { + connectionLimiter.sweep(); + eventLimiter.sweep(); httpServer = createServer(); initRealtime(httpServer); await new Promise((resolve) => httpServer.listen(0, resolve)); @@ -94,36 +102,68 @@ describe("initRealtime (issue #946)", () => { httpServer.close(); }); - it("accepts an authenticated client and emits connected", async () => { - const token = makeToken({ - userId: "user_02", - stellarAddress: "GDEF", - role: "user", - scopes: [], - }); + it('accepts an authenticated client and emits connected', async () => { + const token = makeToken({ userId: 'user-conn' }); clientSocket = ioClient(`http://localhost:${port}`, { auth: { token }, - transports: ["websocket"], + transports: ['websocket'], }); const payload = await new Promise<{ userId: string }>((resolve, reject) => { - clientSocket.on("connected", resolve); - clientSocket.on("connect_error", reject); + clientSocket.on('connected', resolve); + clientSocket.on('connect_error', reject); }); - expect(payload.userId).toBe("user_02"); + expect(payload.userId).toBe('user-conn'); }); - it("rejects a client with no token", async () => { + it('rejects a client with no token', async () => { clientSocket = ioClient(`http://localhost:${port}`, { - transports: ["websocket"], + transports: ['websocket'], }); const err = await new Promise((resolve) => { - clientSocket.on("connect_error", resolve); + clientSocket.on('connect_error', resolve); }); expect(err.message).toMatch(/token/i); }); + + it('joins a creator room on subscribe:creator', async () => { + const token = makeToken({ userId: 'user-sub' }); + clientSocket = ioClient(`http://localhost:${port}`, { + auth: { token }, + transports: ['websocket'], + }); + + await new Promise((resolve) => clientSocket.on('connected', () => resolve())); + + // No server ack for subscribe — this just verifies the handler doesn't error. + let errored = false; + clientSocket.on('error', () => { + errored = true; + }); + clientSocket.emit('subscribe:creator', 'GCREATOR'); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(errored).toBe(false); + }); + + it('rejects subscribing to another user\'s notifications room', async () => { + const token = makeToken({ userId: 'user-a' }); + clientSocket = ioClient(`http://localhost:${port}`, { + auth: { token }, + transports: ['websocket'], + }); + + await new Promise((resolve) => clientSocket.on('connected', () => resolve())); + + const err = await new Promise<{ code: string }>((resolve) => { + clientSocket.on('error', resolve); + clientSocket.emit('subscribe:notifications', 'user-b'); + }); + + expect(err.code).toBe('FORBIDDEN'); + }); }); diff --git a/backend/src/realtime/realtime.types.ts b/backend/src/realtime/realtime.types.ts deleted file mode 100644 index 459d5faf..00000000 --- a/backend/src/realtime/realtime.types.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { AuthPayload } from "@/modules/auth/auth.types.js"; - -/** - * Events the server may emit to a connected client. - */ -export interface ServerToClientEvents { - connected: (payload: { userId: string }) => void; - error: (payload: { message: string }) => void; -} - -/** - * Events a client may emit to the server. - */ -export interface ClientToServerEvents { - ping: (ack: (payload: { pong: true }) => void) => void; -} - -/** - * Events emitted between server instances (unused for now, required by the - * Socket.IO generic signature). - */ -export type InterServerEvents = Record; - -/** - * Per-connection data attached during the auth handshake. - */ -export interface SocketData { - auth: AuthPayload; -} diff --git a/backend/src/realtime/types.ts b/backend/src/realtime/types.ts index 6a4384ae..0f364759 100644 --- a/backend/src/realtime/types.ts +++ b/backend/src/realtime/types.ts @@ -1,10 +1,22 @@ import type { TipResponseDto } from '../modules/tips/tips.dto.js'; +import type { AuthPayload } from '../modules/auth/auth.types.js'; +/** + * Shared typed contract for the Socket.IO gateway. This is the single source + * of truth for event names and payload shapes on both server and client. + */ + +/** Events the server may emit to a connected client. */ export interface ServerToClientEvents { + /** Emitted once, right after a successful auth handshake. */ + connected: (payload: { userId: string }) => void; + /** Emitted for handshake failures, forbidden actions, and rate limiting. */ + error: (payload: { code: string; message: string }) => void; 'tip.created': (tip: TipResponseDto) => void; 'notification.created': (notification: NotificationPayload) => void; } +/** Events a client may emit to the server. */ export interface ClientToServerEvents { 'subscribe:creator': (creatorAddress: string) => void; 'subscribe:notifications': (userId: string) => void; @@ -12,6 +24,14 @@ export interface ClientToServerEvents { 'unsubscribe:notifications': (userId: string) => void; } +/** Events emitted between server instances (unused for now, required by the Socket.IO generic signature). */ +export type InterServerEvents = Record; + +/** Per-connection data attached during the auth handshake. */ +export interface SocketData { + auth: AuthPayload; +} + export interface NotificationPayload { id: string; userId: string; diff --git a/backend/src/server.ts b/backend/src/server.ts index 8b2c1d0d..67f2dc0c 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -10,6 +10,7 @@ import { createCreditRecomputeWorker, scheduleCreditRecompute, } from './jobs/index.js'; +import { initRealtime } from './realtime/index.js'; /** Process entry point: starts the HTTP server (and, later, the WebSocket + indexer). */ async function bootstrap(): Promise { @@ -47,8 +48,8 @@ async function bootstrap(): Promise { }); await scheduleCreditRecompute(); - // The realtime gateway (Socket.IO) attaches to this httpServer — see the realtime issues. - // initRealtime(httpServer); + // The realtime gateway (Socket.IO) attaches to this httpServer. + initRealtime(httpServer); httpServer.listen(env.PORT, () => { logger.info(`🚀 Stellar Tipz backend listening on http://localhost:${env.PORT}`);