Skip to content

fix: WebSocket chat:send — per-user rate limiting, ack payloads, and standardized errors#113

Open
davedumto wants to merge 1 commit intoTevaLabs:mainfrom
davedumto:fix/socket-chat-rate-limiting
Open

fix: WebSocket chat:send — per-user rate limiting, ack payloads, and standardized errors#113
davedumto wants to merge 1 commit intoTevaLabs:mainfrom
davedumto:fix/socket-chat-rate-limiting

Conversation

@davedumto
Copy link
Contributor

@davedumto davedumto commented Mar 24, 2026

Context

##closes #107

The WebSocket chat:send path validated message content but had no abuse controls — a connected client could flood the chat room without any throttling. There were also no acknowledgement payloads (success or failure), and error responses were inconsistent unstructured objects emitted on a generic error event.

This PR closes that gap, bringing the WebSocket chat surface to parity with the HTTP POST /api/chat/send endpoint.


What Changed

src/socket.ts

Per-user rate limiting (SocketRateLimiter)

  • Introduced a SocketRateLimiter class using a sliding-window algorithm keyed by userId.
  • Limit: 5 messages per 60 seconds — identical to the existing HTTP chatMessageRateLimiter.
  • The chatRateLimiter instance is exported so tests can call chatRateLimiter.reset() to isolate state between test cases.
  • When the limit is exceeded the handler returns immediately with { ok: false, code: 'RATE_LIMITED' } and logs a warning; no DB write occurs.

Acknowledgement-based responses

  • chat:send now accepts an optional second argument callback?: (ack: ChatAck) => void, the standard Socket.IO acknowledgement pattern.
  • Success: { ok: true, message: ChatMessage }
  • Failure: { ok: false, error: string, code: 'AUTH_REQUIRED' | 'INVALID_CONTENT' | 'RATE_LIMITED' | 'SEND_FAILED' }
  • Clients that omit the callback are handled gracefully (no-op) — fully backwards-compatible.

Routed through chatService.sendMessage()

  • The previous inline handler duplicated DB logic and bypassed chatService entirely — profanity filtering and wallet address masking did not apply to WebSocket messages.
  • The handler now delegates to chatService.sendMessage(userId, walletAddress, content), making the WebSocket and HTTP paths consistent.

src/tests/socket.spec.ts

Added a chat:send describe block with 8 new tests on top of the existing 9:

Test Asserts
Unauthenticated send { ok: false, code: 'AUTH_REQUIRED' }
Empty / whitespace-only content { ok: false, code: 'INVALID_CONTENT' }
Content > 500 characters { ok: false, code: 'INVALID_CONTENT' }
chatService throws { ok: false, code: 'SEND_FAILED' }
Valid send { ok: true, message: <ChatMessage> }, correct args forwarded to chatService
No callback (fire-and-forget) Server does not crash, connection stays open
Burst — 6 rapid messages First 5 succeed, 6th returns RATE_LIMITED; chatService called exactly 5 times
Post-window reset After chatRateLimiter.reset(), messages are accepted again

Also fixed the afterAll teardown to call httpServer.closeAllConnections() before httpServer.close() — previously the hook could time out when open socket connections prevented the HTTP server from closing cleanly.


Test Run

PASS src/tests/socket.spec.ts
  Socket.IO Auth & Room Events (Issue #78)
    Socket auth
      ✓ should allow connection without token (unauthenticated)
      ✓ should reject connection with invalid token
      ✓ should accept connection with valid JWT and attach user
    Room events
      ✓ should emit room:joined when joining round room
      ✓ should emit room:left when leaving round room
      ✓ should allow authenticated user to join chat and emit room:joined
      ✓ should emit error when unauthenticated user tries to join chat
      ✓ should allow authenticated user to join notifications room
      ✓ should emit error when unauthenticated user tries join:notifications
    chat:send
      ✓ should return AUTH_REQUIRED when unauthenticated socket sends chat:send
      ✓ should return INVALID_CONTENT for empty message
      ✓ should return INVALID_CONTENT for message exceeding 500 characters
      ✓ should return SEND_FAILED when chatService throws
      ✓ should return ok:true with the message on a valid send
      ✓ should not crash when chat:send is emitted without a callback
      ✓ should throttle after 5 messages in a 60-second window (burst test)
      ✓ should allow messages again after rate limit window resets

Tests: 17 passed, 17 total

Definition of Done checklist

  • Socket chat spam is throttled (5 msg/60s per user)
  • Clients receive predictable acknowledgement payloads
  • Error payloads are consistent with typed code values
  • Tests cover normal, invalid, and abusive chat traffic

…s for socket chat:send

- Add SocketRateLimiter class (sliding-window, keyed by userId) to socket.ts
  matching the HTTP chatMessageRateLimiter limit of 5 messages per 60 seconds
- Export chatRateLimiter instance so tests can reset state between cases
- Rewrite chat:send handler to use Socket.IO acknowledgement callbacks,
  returning { ok: true, message } on success and
  { ok: false, error, code } on all failure paths
- Typed error codes: AUTH_REQUIRED | INVALID_CONTENT | RATE_LIMITED | SEND_FAILED
- Route chat:send through chatService.sendMessage() instead of duplicating
  DB logic inline — profanity filtering and wallet address masking now apply
  to WebSocket messages consistently with the HTTP path
- Handle missing callback gracefully (no-op) for backwards-compatible clients
- Add 8 new tests covering: unauthenticated send, empty content, over-500
  chars, chatService error, valid send with ack, no-callback safety, burst
  spam (5 ok, 6th RATE_LIMITED), and post-window-reset recovery
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add websocket chat rate limiting and acknowledgement semantics

1 participant