@nage-api/realtime
v1.0.0-beta.4
Published
Realtime for @nage-api — authenticated sockets and a cross-instance propagator
Readme
@nage-api/realtime
Authenticated sockets and a cross-instance propagator (PLAN.md §8, §12, §25 P2).
§12 names three specific defects in the legacy socket layer. This package exists to make all three unrepresentable.
No token in the query string. A token in a URL is written to every access
log, every proxy log, every Referer sent to a third-party asset, and the
browser history. The handshake reads Authorization: Bearer … and nothing else
— and refuses a connection that puts a credential in the query rather than
quietly ignoring it, because a client sending one has tokens in its URLs and its
author needs to know.
No default guest. A connection with no valid bearer token is refused. The legacy gateway assigned an identity, which made every "authenticated" room reachable by anyone who opened a socket.
Rooms are authorised. Joining a room named by the client is an authorization
decision, so RoomPolicy.canJoin runs on every join and the default policy
refuses everything — an unconfigured gateway must not be an open one. A refusal
never names the room back, because which rooms exist is not something an
unauthorised caller may enumerate.
import { Module } from '@nestjs/common';
import { NageRealtimeModule, type Propagator, type RealtimeTransport } from '@nage-api/realtime';
import type { AuthUser } from '@nage-api/contracts';
// The application's own token verification, over `@nage-api/auth`: a feature package
// may not import a sibling feature package (§7.2), so the socket layer takes the
// resolution as a port.
declare const principals: { fromAccessToken(token: string): Promise<AuthUser> };
// Both bindings are yours today; neither the Socket.IO transport nor the Redis
// propagator is written (see "Not yet implemented").
declare const socketIo: RealtimeTransport;
declare const redisPropagator: Propagator;
@Module({
imports: [
NageRealtimeModule.forRoot({
realtime: { enabled: true, adapter: 'redis' },
authenticator: { authenticate: (token) => principals.fromAccessToken(token) },
roomPolicy: { canJoin: (user, room) => room === `user:${String(user.id)}` },
transport: socketIo,
propagator: redisPropagator,
}),
],
})
export class AppModule {}authenticator is required and has no default, because the only possible
default would be "accept anyone". realtime.enabled must be true: anything
else contributes no providers at all, so RealtimeGateway is unavailable rather
than present and inert.
Across instances
Without a propagator, a two-pod deployment delivers each event to whichever fraction of users happens to be connected to the pod that emitted it. Events carry the emitting instance's id, so an event that comes back from the propagator is delivered locally exactly once rather than looping between pods.
Asking for adapter: 'redis' without supplying a propagator is refused —
accepting it would be a silent lie.
Connections per principal are bounded, because a client in a reconnect loop otherwise accumulates sockets until the process runs out of file descriptors.
Transport-independent
Which socket library carries the bytes is a binding; who may connect, who may join what, and what happens on a second instance are decisions. The decisions live here, where they are testable — testing them through a real socket server would be testing Socket.IO.
With no transport supplied the gateway gets MemoryTransport, which records
every emission and delivers to nobody. That is the right default for a test and
the wrong one for a deployment: until the Socket.IO binding lands, a running
application needs a RealtimeTransport of its own or its events go nowhere.
Not yet implemented
- The Socket.IO binding (§27.5) and the Redis propagator.
RealtimeTransportandPropagatorare the seams. - Per-connection rate limiting, and presence (who is in a room, across instances).
