Skip to content

Auction WebSocket Infrastructure

Auction realtime traffic now uses a single enterprise WebSocket interface at:

wss://api.example.com/ws

There are no role-specific WebSocket endpoints for subscribers, companies, or admins. Every client connects to /ws; JWT authentication creates a typed socket context, and message-level authorization controls what the connection can do.

Code Layout

src/
├── infrastructure/
│   └── websocket/
│       ├── broadcaster.ts
│       ├── connection-manager.ts
│       ├── heartbeat.ts
│       ├── redis-pubsub.ts
│       ├── room-manager.ts
│       ├── socket-registry.ts
│       └── types.ts
└── interfaces/
    └── websocket/
        ├── index.ts
        ├── connection.ts
        ├── router.ts
        ├── auth.ts
        ├── context.ts
        ├── errors.ts
        ├── middleware/
        ├── handlers/
        ├── schemas/
        └── types/

WebSocket code intentionally lives outside src/interfaces/http.

Boot Flow

src/interfaces/app.ts registers the WebSocket interface after HTTP routes and webhooks:

await app.register(websocketInterface);

src/infrastructure/plugins/00-websocket.ts registers @fastify/websocket and configures:

  • maxPayload from config.websocket.connectionMaxPayloadBytes
  • accepted protocols: auction-room.v1, websocket.v1, and bearer.<token>

src/interfaces/websocket/index.ts creates one WebSocketRouter, registers all current socket routes, subscribes the local process to ws:global, and exposes:

GET /ws

Configuration

The WebSocket config is loaded through the normal app config pipeline.

WEBSOCKET_HEARTBEAT_INTERVAL_MS=20000
WEBSOCKET_CONNECTION_MAX_PAYLOAD_BYTES=65536
WEBSOCKET_RATE_LIMIT_WINDOW_MS=60000
WEBSOCKET_DEFAULT_RATE_LIMIT=120

All values must be positive integers. Event handlers can override the default rate limit per route.

Authentication

Authentication happens immediately after the socket upgrades and before any message is processed.

Supported transports:

Authorization: Bearer <access-token>
Sec-WebSocket-Protocol: websocket.v1, bearer.<access-token>

The token is verified through jwtService.verifyAccessToken. Business handlers never decode JWTs or read auth headers.

The JWT payload is converted into:

interface SocketUser {
  readonly id: string;
  readonly role: Role;
  readonly companyId?: string;
  readonly permissions: readonly string[];
}

Role-derived v1 permissions:

SUBSCRIBER: auction:subscribe, auction:bid:place, room:join, room:leave
COMPANY: auction:subscribe, auction:staff, room:join, room:leave, broadcast:receive
SUPERADMIN: auction:subscribe, auction:staff, room:join, room:leave, broadcast:receive
Other roles: room:join, room:leave

For COMPANY, companyId is currently set from userId.

Authentication failure closes the socket with close code 1008 and reason Authentication failed.

Socket Context

Every accepted connection receives a strongly typed SocketContext:

interface SocketContext {
  readonly socket: WebSocket;
  readonly user: SocketUser;
  readonly rooms: Set<string>;
  readonly requestId: string;
  readonly connectionId: string;
  readonly ip: string;
  readonly connectedAt: Date;
  readonly logger: FastifyBaseLogger;
  readonly subscriptions: Map<string, () => Promise<void>>;
}

connectionId and requestId are generated with @paralleldrive/cuid2. The context logger is a child logger containing requestId, connectionId, userId, and role.

Message Envelope

Incoming messages use:

{
  "type": "auction.subscribe",
  "requestId": "client-request-id",
  "data": {}
}

Envelope rules:

  • type: required string, 1 to 128 characters
  • requestId: optional string, 1 to 128 characters
  • data: optional unknown payload, validated by the event route schema

Successful handler responses are wrapped as acknowledgements:

{
  "type": "ack",
  "requestId": "client-request-id",
  "eventType": "auction.bid.place",
  "data": {}
}

Handlers may return null to suppress the acknowledgement.

Errors use:

{
  "type": "error",
  "requestId": "client-request-id",
  "code": "BAD_REQUEST",
  "message": "Safe client-facing message."
}

Router Pipeline

WebSocketRouter is registry-based. Each route is registered by event type; there are no large switch statements.

Pipeline order:

  1. Parse raw message as JSON
  2. Validate the base envelope
  3. Find the registered route by type
  4. Authenticate the context
  5. Apply per-user and per-IP rate limits
  6. Validate the event-specific Zod schema
  7. Authorize required route permissions
  8. Execute the handler
  9. Send ack or error

Middleware is composable:

type SocketMiddleware = (input: {
  readonly context: SocketContext;
  readonly message: SocketMessageEnvelope;
  readonly route: SocketRoute;
}) => Promise<void>;

Current middleware:

  • authenticateMiddleware
  • makeRateLimitMiddleware
  • validateMiddleware
  • authorizeMiddleware

Error Mapping

Centralized WebSocket error mapping lives in src/interfaces/websocket/errors.ts.

ZodError, SyntaxError, BadRequestError -> BAD_REQUEST
UnauthorizedError -> UNAUTHORIZED
ForbiddenError -> FORBIDDEN
NotFoundError -> NOT_FOUND
ConflictError -> CONFLICT
WebSocketRateLimitError -> RATE_LIMITED
Unexpected error -> INTERNAL

Unexpected failures are logged with the socket context logger and return a safe client message.

Current Events

system.ping

Schema:

{}

Response acknowledgement data:

{
  "type": "system.pong",
  "occurredAt": "2026-08-03T00:00:00.000Z"
}

room.join

Required permission:

room:join

Schema:

{
  "room": "auction:cycle-id"
}

Allowed room namespaces:

  • notifications
  • admins, only for SUPERADMIN
  • user:<current-user-id>
  • company:<current-company-id>
  • auction:<cycleId>

Successful acknowledgement data:

{
  "room": "auction:cycle-id",
  "joined": true
}

room.leave

Required permission:

room:leave

Schema:

{
  "room": "auction:cycle-id"
}

Successful acknowledgement data:

{
  "room": "auction:cycle-id",
  "left": true
}

auction.subscribe

Required permission:

auction:subscribe

Schema:

{
  "cycleId": "cycle-id",
  "enrolledSubscriberId": "enrolled-subscriber-id",
  "lastSequence": 12
}

enrolledSubscriberId is required by the auction application authorization flow for subscriber users and omitted by staff users. lastSequence is optional; if provided, the server merges replay events into the snapshot payload.

Behavior:

  • Authorizes access through makeAuctionRoomService
  • Joins local room auction:<cycleId>
  • Ensures Redis subscription to ws:room:auction:<cycleId>
  • Subscribes to the existing auction realtime channel through auctionRealtimeService.subscribe(cycleId, listener)
  • Records subscriber presence through auctionPresenceService.join for subscriber connections
  • Sends auction.connected
  • Sends auction.snapshot
  • Sends presence.snapshot
  • Returns an acknowledgement containing auctionId, cycleId, and room

Initial server events:

{
  "type": "auction.connected",
  "requestId": "subscribe-001",
  "data": {
    "auctionId": "auction-id",
    "cycleId": "cycle-id",
    "occurredAt": "2026-08-03T00:00:00.000Z",
    "payload": {
      "connectionId": "connection-id",
      "heartbeatIntervalMs": 20000
    }
  }
}
{
  "type": "auction.snapshot",
  "requestId": "subscribe-001",
  "data": {
    "auctionId": "auction-id",
    "cycleId": "cycle-id",
    "occurredAt": "2026-08-03T00:00:00.000Z",
    "payload": {}
  }
}
{
  "type": "presence.snapshot",
  "requestId": "subscribe-001",
  "data": {
    "auctionId": "auction-id",
    "cycleId": "cycle-id",
    "occurredAt": "2026-08-03T00:00:00.000Z",
    "payload": {
      "onlineSubscribers": []
    }
  }
}

Successful acknowledgement data:

{
  "auctionId": "auction-id",
  "cycleId": "cycle-id",
  "room": "auction:cycle-id"
}

auction.unsubscribe

Required permission:

auction:subscribe

Schema:

{
  "cycleId": "cycle-id"
}

Behavior:

  • Finds the connection subscription for auction:<cycleId>
  • Leaves the local room
  • Removes subscriber presence when applicable
  • Calls the auction realtime unsubscribe cleanup
  • Returns whether an active subscription was found

Successful acknowledgement data:

{
  "cycleId": "cycle-id",
  "unsubscribed": true
}

auction.bid.place

Required permission:

auction:bid:place

Rate limit:

100 messages per WEBSOCKET_RATE_LIMIT_WINDOW_MS

Schema:

{
  "cycleId": "cycle-id",
  "amount": "25000",
  "idempotencyKey": "bid-request-id",
  "enrolledSubscriberId": "enrolled-subscriber-id"
}

amount may be a positive number or a non-empty decimal string. Decimal strings are preferred to avoid JavaScript precision issues.

Behavior:

  • Only SUBSCRIBER users may execute this event
  • Authorizes the auction room with makeAuctionRoomService
  • Delegates bid placement to the existing auction application use cases
  • Uses idempotencyKey as the internal auction request id
  • Emits domain realtime updates through the existing auction realtime service
  • Returns the bid command result as acknowledgement data

Staff Command Events

The following events execute staff auction commands through the auction room service. They all require the auction:staff permission (COMPANY and SUPERADMIN only) and the cycleId field. Subscribers receive a FORBIDDEN error. Each event accepts an optional requestId (a server-generated id is used when omitted).

auction.status.update

Start the live auction.

{
  "cycleId": "cycle-id",
  "status": "START"
}

auction.pause / auction.resume / auction.end

Toggle and close an auction.

{
  "cycleId": "cycle-id",
  "reason": "required reason"
}

Each returns the corresponding auction command result (e.g. { "stateVersion": 3 }).

bid.mark

Performs a staff bid-moderation action. One of:

{ "cycleId": "cycle-id", "action": "DELETE_BID", "bidId": "bid-id", "reason": "..." }
{
  "cycleId": "cycle-id",
  "action": "DISQUALIFY_BIDDER",
  "enrolledSubscriberId": "enrolled-id",
  "reason": "...",
  "reasonCode": "RULE_VIOLATION"
}
{
  "cycleId": "cycle-id",
  "action": "DISQUALIFY_CANDIDATE_AND_DECLARE_NEXT",
  "bidId": "bid-id",
  "reason": "...",
  "reasonCode": "RULE_VIOLATION"
}

reasonCode is one of KYC_ISSUE | PAYMENT_ISSUE | ELIGIBILITY_ISSUE | RULE_VIOLATION | OTHER and is required except for DELETE_BID.

winner.declare

Declare an auction winner, either from an existing bid/candidate or manually.

{ "cycleId": "cycle-id", "mode": "CANDIDATE", "bidId": "bid-id" }
{
  "cycleId": "cycle-id",
  "mode": "MANUAL",
  "enrolledSubscriberId": "enrolled-id",
  "amount": "25000",
  "reason": "required for manual"
}

winner.record_lot

Record a manual lot winner with at least two runner-up candidates.

{
  "cycleId": "cycle-id",
  "winnerEnrolledSubscriberId": "enrolled-id",
  "candidateEnrolledSubscriberIds": ["enrolled-id-a", "enrolled-id-b"],
  "reason": "optional"
}

Server Broadcast Events

Auction realtime events are forwarded to socket clients using the event type published by the auction realtime service, for example:

{
  "type": "bid.created",
  "requestId": "bid-request-id",
  "data": {
    "auctionId": "auction-id",
    "cycleId": "cycle-id",
    "stateVersion": 7,
    "occurredAt": "2026-08-03T00:00:00.000Z",
    "payload": {
      "bidId": "bid-id",
      "leadingBidAmount": 25000
    }
  }
}

If an auction realtime event contains targetUserId, subscriber sockets only receive it when targetUserId matches the authenticated socket user.

Infrastructure Services

SocketRegistry

Stores RegisteredSocket by connectionId.

Operations:

  • register
  • remove
  • get
  • list
  • size

ConnectionManager

Wraps SocketRegistry and tracks multiple simultaneous connections per user.

Operations:

  • register
  • remove
  • getByConnectionId
  • getByUserId
  • list
  • metrics

Metrics:

interface ConnectionMetrics {
  readonly activeConnections: number;
  readonly activeUsers: number;
}

RoomManager

Maintains local room membership for the current Node.js process.

Operations:

  • join(connectionId, room)
  • leave(connectionId, room)
  • removeConnection(connectionId)
  • getConnectionRooms(connectionId)
  • getRoomMembers(room)

Room membership is intentionally local. Cross-instance delivery is handled by Redis Pub/Sub through Broadcaster.

RedisPubSub

Typed Redis Pub/Sub abstraction. It publishes through the app Redis client and uses a duplicated Redis connection for subscriptions.

Message shape:

interface WebSocketPubSubMessage {
  readonly target: 'room' | 'global';
  readonly room?: string;
  readonly message: WebSocketBroadcastMessage;
  readonly excludeConnectionId?: string;
}

Standard channels:

ws:global
ws:room:<roomName>

auctionRealtimeService remains the compatibility layer for existing auction domain realtime channels.

Broadcaster

Owns socket delivery and Redis-backed fanout.

Operations:

  • sendToSocket
  • sendToConnection
  • sendToUser
  • broadcastToRoom
  • broadcastToRooms
  • broadcastGlobal
  • deliverFromPubSub
  • ensureRoomSubscription
  • getRoomChannel

Room and global broadcasts publish to Redis by default. Local-only delivery is available for process-scoped sends.

Heartbeat

Sends WebSocket protocol pings on the configured interval. Each connection must respond with pong before the next interval or onDead terminates the socket.

The heartbeat cleanup function is called during connection cleanup.

Connection Lifecycle

On connection:

  1. Verify JWT and build SocketUser
  2. Build SocketContext
  3. Register the connection
  4. Start heartbeat
  5. Attach message, close, and error listeners

On close or error:

  1. Stop heartbeat
  2. Run every subscription cleanup in context.subscriptions
  3. Leave all local rooms
  4. Remove connection from the connection manager
  5. Log closure

During application shutdown, disposeDependencies disposes WebSocket Pub/Sub, auction presence, auction realtime services, Redis, queues, database, and the DI container.

Logging

The implementation logs:

  • Authentication failures
  • Successful connections
  • Connection errors
  • Missed heartbeat termination
  • Subscription cleanup failures
  • Unexpected message processing failures
  • Redis subscriber initialization, parse, and runtime errors

Room joins, room leaves, and broadcasts are represented in the service calls and can be expanded with additional structured log statements if operational visibility needs more granularity.

Rate Limiting

Rate limiting is currently in-memory per Node.js process and keyed by both:

user:<userId>:<eventType>
ip:<ip>:<eventType>

Default limit:

WEBSOCKET_DEFAULT_RATE_LIMIT per WEBSOCKET_RATE_LIMIT_WINDOW_MS

auction.bid.place overrides the default with 100 messages per configured window. In horizontally scaled deployments, use Redis-backed rate limiting if the same limit must be enforced globally across all WebSocket servers.

Adding A New Event

  1. Add a Zod schema near the handler module.
  2. Create a SocketRoute with type, schema, optional permissions, optional rateLimit, and a typed handler.
  3. Register the route from src/interfaces/websocket/handlers/index.ts.
  4. Keep business rules inside application use cases.
  5. Throw typed shared errors instead of manually sending error envelopes.
  6. Add unit or integration tests for validation, authorization, and handler behavior.

Example:

const notificationReadSchema = z.object({
  notificationId: z.string().min(1)
});

const route: SocketRoute<typeof notificationReadSchema> = {
  type: 'notification.read',
  schema: notificationReadSchema,
  permissions: ['notification:read'],
  handler: async ({ data }) => {
    return { notificationId: data.notificationId, read: true };
  }
};

Removed Legacy Routes

Legacy subscriber, company, admin, and bid-activity WebSocket route files have been removed. HTTP auction routes remain in place; only realtime WebSocket traffic moved to /ws.

Verification

Current focused checks:

pnpm typecheck
pnpm vitest run --config vitest.config.mts src/interfaces/websocket/__tests__/websocket-interface.test.ts
pnpm lint:eslint
graphify update .