From 9d3fb79b6d5ab9baae9242529f5af7dd0bb17017 Mon Sep 17 00:00:00 2001 From: John Chantzigoulas Date: Fri, 4 Sep 2026 14:43:54 +0300 Subject: [PATCH 1/4] feat(router): add ReBAC-protected event-to-socket relays Forward exact Redis bus events as templated Socket.io messages on /events/, with resource-scoped rooms and local-only HA emission. --- libraries/hermes/src/Socket/Socket.ts | 25 +++- libraries/hermes/src/interfaces/Socket.ts | 9 +- libraries/hermes/src/interfaces/SocketPush.ts | 1 + modules/router/README.mdx | 40 +++++ modules/router/package.json | 1 + modules/router/src/Router.ts | 28 +++- modules/router/src/admin/event-relays.ts | 128 ++++++++++++++++ modules/router/src/admin/index.ts | 96 +++++++++++- .../src/event-relays/EventRelayManager.ts | 138 ++++++++++++++++++ .../event-relays/EventRelaySockets.test.ts | 91 ++++++++++++ .../src/event-relays/EventRelaySockets.ts | 96 ++++++++++++ modules/router/src/event-relays/authorize.ts | 101 +++++++++++++ .../router/src/event-relays/channels.test.ts | 32 ++++ modules/router/src/event-relays/channels.ts | 23 +++ modules/router/src/event-relays/constants.ts | 32 ++++ modules/router/src/event-relays/index.ts | 25 ++++ modules/router/src/event-relays/path.test.ts | 39 +++++ modules/router/src/event-relays/path.ts | 49 +++++++ .../router/src/event-relays/process.test.ts | 85 +++++++++++ modules/router/src/event-relays/process.ts | 75 ++++++++++ modules/router/src/event-relays/push.ts | 28 ++++ modules/router/src/event-relays/rooms.test.ts | 29 ++++ modules/router/src/event-relays/rooms.ts | 6 + .../router/src/event-relays/search.test.ts | 34 +++++ modules/router/src/event-relays/search.ts | 26 ++++ .../router/src/event-relays/template.test.ts | 39 +++++ modules/router/src/event-relays/template.ts | 136 +++++++++++++++++ .../src/event-relays/validation.test.ts | 69 +++++++++ modules/router/src/event-relays/validation.ts | 130 +++++++++++++++++ .../src/event-relays/validationError.ts | 6 + modules/router/src/metrics/index.ts | 21 +++ .../router/src/models/EventRelay.schema.ts | 94 ++++++++++++ modules/router/src/models/index.ts | 1 + modules/router/tsconfig.test.json | 24 +++ 34 files changed, 1750 insertions(+), 7 deletions(-) create mode 100644 modules/router/src/admin/event-relays.ts create mode 100644 modules/router/src/event-relays/EventRelayManager.ts create mode 100644 modules/router/src/event-relays/EventRelaySockets.test.ts create mode 100644 modules/router/src/event-relays/EventRelaySockets.ts create mode 100644 modules/router/src/event-relays/authorize.ts create mode 100644 modules/router/src/event-relays/channels.test.ts create mode 100644 modules/router/src/event-relays/channels.ts create mode 100644 modules/router/src/event-relays/constants.ts create mode 100644 modules/router/src/event-relays/index.ts create mode 100644 modules/router/src/event-relays/path.test.ts create mode 100644 modules/router/src/event-relays/path.ts create mode 100644 modules/router/src/event-relays/process.test.ts create mode 100644 modules/router/src/event-relays/process.ts create mode 100644 modules/router/src/event-relays/push.ts create mode 100644 modules/router/src/event-relays/rooms.test.ts create mode 100644 modules/router/src/event-relays/rooms.ts create mode 100644 modules/router/src/event-relays/search.test.ts create mode 100644 modules/router/src/event-relays/search.ts create mode 100644 modules/router/src/event-relays/template.test.ts create mode 100644 modules/router/src/event-relays/template.ts create mode 100644 modules/router/src/event-relays/validation.test.ts create mode 100644 modules/router/src/event-relays/validation.ts create mode 100644 modules/router/src/event-relays/validationError.ts create mode 100644 modules/router/src/models/EventRelay.schema.ts create mode 100644 modules/router/tsconfig.test.json diff --git a/libraries/hermes/src/Socket/Socket.ts b/libraries/hermes/src/Socket/Socket.ts index 7d9b0b09f..74ced4e2b 100644 --- a/libraries/hermes/src/Socket/Socket.ts +++ b/libraries/hermes/src/Socket/Socket.ts @@ -10,6 +10,7 @@ import { EventResponse, isInstanceOfEventResponse, JoinRoomResponse, + LeaveRoomResponse, SocketPush, } from '../interfaces/index.js'; import ObjectHash from 'object-hash'; @@ -184,11 +185,13 @@ export class SocketController extends ConduitRouter { } async handleSocketPush(push: SocketPush) { + const localOnly = push.localOnly === true; if (push.event === 'join-room') { if (push.rooms.length === 0) return; const filteredSockets = await this.findAndFilterSockets( push.receivers, push.namespace, + localOnly, ); for (const socket of filteredSockets) { ConduitGrpcSdk.Logger.info( @@ -203,6 +206,7 @@ export class SocketController extends ConduitRouter { const filteredSockets = await this.findAndFilterSockets( push.receivers, push.namespace, + localOnly, ); for (const socket of filteredSockets) { for (const room of push.rooms) { @@ -221,7 +225,12 @@ export class SocketController extends ConduitRouter { ConduitGrpcSdk.Logger.info( `Emitting event: ${push.event} to all sockets in namespace: ${push.namespace}`, ); - this.io.of(push.namespace).emit(push.event, push.data); + const nsp = this.io.of(push.namespace); + if (localOnly) { + nsp.local.emit(push.event, push.data); + } else { + nsp.emit(push.event, push.data); + } } else { if (push.rooms.length !== 0) { ConduitGrpcSdk.Logger.info( @@ -229,12 +238,18 @@ export class SocketController extends ConduitRouter { ', ', )} in namespace: ${push.namespace}`, ); - this.io.of(push.namespace).to(push.rooms).emit(push.event, push.data); + const target = this.io.of(push.namespace).to(push.rooms); + if (localOnly) { + target.local.emit(push.event, push.data); + } else { + target.emit(push.event, push.data); + } } if (push.receivers.length !== 0) { const filteredSockets = await this.findAndFilterSockets( push.receivers, push.namespace, + localOnly, ); for (const socket of filteredSockets) { ConduitGrpcSdk.Logger.info( @@ -248,7 +263,7 @@ export class SocketController extends ConduitRouter { } private async handleResponse( - res: EventResponse | JoinRoomResponse, + res: EventResponse | JoinRoomResponse | LeaveRoomResponse, socket: Socket, namespace: string, ) { @@ -307,8 +322,10 @@ export class SocketController extends ConduitRouter { async findAndFilterSockets( userIds: string[], namespace: string, + localOnly: boolean = false, ): Promise[]> { - const sockets = await this.io.of(namespace).fetchSockets(); + const nsp = this.io.of(namespace); + const sockets = localOnly ? await nsp.local.fetchSockets() : await nsp.fetchSockets(); const userIdSet = new Set(userIds); return sockets.filter(socket => { if (socket.data && socket.data.user) { diff --git a/libraries/hermes/src/interfaces/Socket.ts b/libraries/hermes/src/interfaces/Socket.ts index a477c5c47..204707624 100644 --- a/libraries/hermes/src/interfaces/Socket.ts +++ b/libraries/hermes/src/interfaces/Socket.ts @@ -40,7 +40,14 @@ export type JoinRoomResponse = { rooms: string[]; }; -export type ConduitSocketHandlerResponse = Promise; +export type LeaveRoomResponse = { + event: 'leave-room'; + rooms: string[]; +}; + +export type ConduitSocketHandlerResponse = Promise< + EventResponse | JoinRoomResponse | LeaveRoomResponse +>; export type ConduitSocketEventHandler = ( request: ConduitSocketParameters, diff --git a/libraries/hermes/src/interfaces/SocketPush.ts b/libraries/hermes/src/interfaces/SocketPush.ts index 2a2ca5e10..8bbabbc85 100644 --- a/libraries/hermes/src/interfaces/SocketPush.ts +++ b/libraries/hermes/src/interfaces/SocketPush.ts @@ -4,4 +4,5 @@ export interface SocketPush { receivers: string[]; rooms: string[]; namespace: string; + localOnly?: boolean; } diff --git a/modules/router/README.mdx b/modules/router/README.mdx index b1dff474d..c896f2cee 100644 --- a/modules/router/README.mdx +++ b/modules/router/README.mdx @@ -77,3 +77,43 @@ If you sent any other string that those provided conduit will not utilize cachin The number that you specify also sets the expiry time of conduit's cache. All caching takes place, AFTER middleware execution - cacheControl?: string; + +## Event Relays + +Event Relays map an **exact** Redis bus channel to a Socket.io event on the dedicated `/events/` namespace. +They are configured through the Admin API (`/router/event-relays`) and are **ephemeral**: Redis pub/sub is not replayed, and missed events are not stored. + +Each relay stores a ReBAC check (`permission` on `resourceType:resourceId`). Clients never choose a raw room name. Optional `notes` describe the relay for operators. + +### Client contract + +```javascript +import { io } from 'socket.io-client'; + +const socket = io(`${SOCKET_BASE_URL}/events/`, { + path: '/realtime', + extraHeaders: { + authorization: `Bearer ${accessToken}`, + }, +}); + +socket.emit('subscribe', relayId, resourceId); +socket.on('order-updated', payload => { + // payload is the rendered JSON template +}); +socket.emit('unsubscribe', relayId, resourceId); +``` + +- Subscribe is allowed only when `User:` has the relay `permission` on `:`. +- The outbound event name is the relay `socketEvent`. +- Placeholders in `messageTemplate` use `{{payload.path}}` against the bus JSON payload. + +### Admin API + +| Method | Path | Description | +|:-------|:-----|:------------| +| `GET` | `/router/event-relays` | Paginated list (`skip`, `limit`, `search`) | +| `GET` | `/router/event-relays/:id` | Single relay | +| `POST` | `/router/event-relays` | Create | +| `PATCH` | `/router/event-relays/:id` | Update | +| `DELETE` | `/router/event-relays/:id` | Delete | diff --git a/modules/router/package.json b/modules/router/package.json index 72670f268..c8d9c12cf 100644 --- a/modules/router/package.json +++ b/modules/router/package.json @@ -23,6 +23,7 @@ "prebuild:bundle": "pnpm --filter @conduitplatform/service-bundle run build", "build:bundle": "rimraf bundle && node ../../libraries/service-bundle/dist/cli.js generate-manifest && tsup && node ../../libraries/service-bundle/dist/cli.js copy-assets && node ../../libraries/service-bundle/dist/cli.js generate-lockfile", "generateTypes": "sh build.sh", + "test": "npx tsc -p tsconfig.test.json && node --test dist-test/event-relays/*.test.js", "build:docker": "docker build -t ghcr.io/conduitplatform/router:latest -f ./Dockerfile ../../ && docker push ghcr.io/conduitplatform/router:latest" }, "license": "ISC", diff --git a/modules/router/src/Router.ts b/modules/router/src/Router.ts index 80f9aa56e..0a3867ed6 100644 --- a/modules/router/src/Router.ts +++ b/modules/router/src/Router.ts @@ -40,6 +40,11 @@ import * as adminRoutes from './admin/routes/index.js'; import metricsSchema from './metrics/index.js'; import { ConfigController, ManagedModule } from '@conduitplatform/module-tools'; import { fileURLToPath } from 'node:url'; +import { + createEventRelayPusher, + createEventsSocket, + EventRelayManager, +} from './event-relays/index.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -69,6 +74,8 @@ export default class ConduitDefaultRouter extends ManagedModule { private hasAppliedMiddleware: string[] = []; private _refreshTimeout: NodeJS.Timeout | null = null; private _haInitialized = false; + private eventRelayManager: EventRelayManager; + private eventsSocket?: ConduitSocket; constructor(peerManifestRoot?: string) { super('router', peerManifestRoot); @@ -102,7 +109,16 @@ export default class ConduitDefaultRouter extends ManagedModule { } async onRegister() { - this.adminRouter = new AdminHandlers(this.grpcServer, this.grpcSdk, this); + this.eventRelayManager = new EventRelayManager( + this.grpcSdk, + createEventRelayPusher(data => this._internalRouter.socketPush(data)), + ); + this.adminRouter = new AdminHandlers( + this.grpcServer, + this.grpcSdk, + this, + this.eventRelayManager, + ); this._security = new SecurityModule(this.grpcSdk, this); } @@ -132,8 +148,11 @@ export default class ConduitDefaultRouter extends ManagedModule { } if (config.transports.sockets) { this._internalRouter.initSockets(); + this.registerEventsNamespace(); + await this.eventRelayManager.start(); atLeastOne = true; } else { + await this.eventRelayManager?.stop(); this._internalRouter.stopSockets(); } @@ -336,6 +355,13 @@ export default class ConduitDefaultRouter extends ManagedModule { this._internalRouter.registerConduitRoute(route); } + private registerEventsNamespace() { + if (!this.eventsSocket) { + this.eventsSocket = createEventsSocket(this.grpcSdk, this.eventRelayManager); + } + this._internalRouter.registerConduitSocket(this.eventsSocket); + } + protected registerSchemas(): Promise { const promises = Object.values(models).map(model => { const modelInstance = model.getInstance(this.grpcSdk.database!); diff --git a/modules/router/src/admin/event-relays.ts b/modules/router/src/admin/event-relays.ts new file mode 100644 index 000000000..2e56fbd15 --- /dev/null +++ b/modules/router/src/admin/event-relays.ts @@ -0,0 +1,128 @@ +import { status } from '@grpc/grpc-js'; +import { + GrpcError, + ParsedRouterRequest, + Query, + UnparsedRouterResponse, +} from '@conduitplatform/grpc-sdk'; +import { isNil } from 'lodash-es'; +import { EventRelay } from '../models/index.js'; +import { EventRelayManager } from '../event-relays/EventRelayManager.js'; +import { EventRelayInput, validateEventRelayInput } from '../event-relays/validation.js'; +import { EventRelayValidationError } from '../event-relays/validationError.js'; +import { buildSearchQuery, parsePagination } from '../event-relays/search.js'; + +export class EventRelayAdmin { + constructor(private readonly manager: EventRelayManager) {} + + async listEventRelays(call: ParsedRouterRequest): Promise { + const { skip, limit } = parsePagination( + call.request.params.skip, + call.request.params.limit, + ); + const { search } = call.request.params; + const query = buildSearchQuery(search) as Query; + const relays = await EventRelay.getInstance().findMany(query, { + skip, + limit, + sort: { updatedAt: -1 }, + }); + const count = await EventRelay.getInstance().countDocuments(query); + return { relays, count }; + } + + async getEventRelay(call: ParsedRouterRequest): Promise { + const relay = await EventRelay.getInstance().findOne({ + _id: call.request.params.id, + }); + if (isNil(relay)) { + throw new GrpcError(status.NOT_FOUND, 'Event relay not found'); + } + return relay; + } + + async createEventRelay(call: ParsedRouterRequest): Promise { + const input = parseInput(call.request.params as EventRelayInput); + await assertUniqueName(input.name); + const relay = await EventRelay.getInstance().create({ + ...input, + messageTemplate: input.messageTemplate as EventRelay['messageTemplate'], + }); + await this.manager.notifyChanged(); + return relay; + } + + async patchEventRelay(call: ParsedRouterRequest): Promise { + const existing = await EventRelay.getInstance().findOne({ + _id: call.request.params.id, + }); + if (isNil(existing)) { + throw new GrpcError(status.NOT_FOUND, 'Event relay not found'); + } + + const merged: EventRelayInput = { + name: call.request.params.name ?? existing.name, + notes: + call.request.params.notes === undefined + ? existing.notes + : call.request.params.notes, + active: + call.request.params.active === undefined + ? existing.active + : call.request.params.active, + busEvent: call.request.params.busEvent ?? existing.busEvent, + socketEvent: call.request.params.socketEvent ?? existing.socketEvent, + resourceType: call.request.params.resourceType ?? existing.resourceType, + resourceIdPath: call.request.params.resourceIdPath ?? existing.resourceIdPath, + permission: call.request.params.permission ?? existing.permission, + messageTemplate: + call.request.params.messageTemplate === undefined + ? existing.messageTemplate + : call.request.params.messageTemplate, + }; + const input = parseInput(merged); + if (input.name !== existing.name) { + await assertUniqueName(input.name, existing._id); + } + + const updated = await EventRelay.getInstance().findByIdAndUpdate(existing._id, { + ...input, + messageTemplate: input.messageTemplate as EventRelay['messageTemplate'], + }); + await this.manager.notifyChanged(); + return updated!; + } + + async deleteEventRelay(call: ParsedRouterRequest): Promise { + const existing = await EventRelay.getInstance().findOne({ + _id: call.request.params.id, + }); + if (isNil(existing)) { + throw new GrpcError(status.NOT_FOUND, 'Event relay not found'); + } + await EventRelay.getInstance().deleteOne({ _id: existing._id }); + await this.manager.notifyChanged(); + return { message: 'Event relay deleted' }; + } +} + +function parseInput(params: EventRelayInput): EventRelayInput { + try { + return validateEventRelayInput(params); + } catch (err) { + if (err instanceof EventRelayValidationError) { + throw new GrpcError(status.INVALID_ARGUMENT, err.message); + } + throw err; + } +} + +async function assertUniqueName(name: string, excludeId?: string): Promise { + const existing = await EventRelay.getInstance().findOne({ name }); + if (existing && existing._id !== excludeId) { + throw new GrpcError( + status.ALREADY_EXISTS, + `An event relay named '${name}' already exists`, + ); + } +} diff --git a/modules/router/src/admin/index.ts b/modules/router/src/admin/index.ts index 72065b6f9..0c3231378 100644 --- a/modules/router/src/admin/index.ts +++ b/modules/router/src/admin/index.ts @@ -6,27 +6,34 @@ import { } from '@conduitplatform/grpc-sdk'; import { ConduitBoolean, + ConduitJson, + ConduitNumber, ConduitString, GrpcServer, RoutingManager, } from '@conduitplatform/module-tools'; import { RouterAdmin } from './router.js'; import { SecurityAdmin } from './security.js'; +import { EventRelayAdmin } from './event-relays.js'; import ConduitDefaultRouter from '../Router.js'; -import { Client } from '../models/index.js'; +import { Client, EventRelay } from '../models/index.js'; +import { EventRelayManager } from '../event-relays/EventRelayManager.js'; export class AdminHandlers { private readonly routerAdmin: RouterAdmin; private readonly securityAdmin: SecurityAdmin; + private readonly eventRelayAdmin: EventRelayAdmin; private readonly routingManager: RoutingManager; constructor( private readonly server: GrpcServer, private readonly grpcSdk: ConduitGrpcSdk, private readonly router: ConduitDefaultRouter, + eventRelayManager: EventRelayManager, ) { this.routerAdmin = new RouterAdmin(this.grpcSdk, router); this.securityAdmin = new SecurityAdmin(this.grpcSdk); + this.eventRelayAdmin = new EventRelayAdmin(eventRelayManager); this.routingManager = new RoutingManager(this.grpcSdk.admin, this.server); this.registerAdminRoutes(); } @@ -148,6 +155,93 @@ export class AdminHandlers { new ConduitRouteReturnDefinition('UpdateSecurityClient', Client.name), this.securityAdmin.updateSecurityClient.bind(this.securityAdmin), ); + + this.routingManager.route( + { + path: '/event-relays', + action: ConduitRouteActions.GET, + description: `Returns event-to-socket relays. Delivery is ephemeral Redis pub/sub with no replay.`, + queryParams: { + skip: ConduitNumber.Optional, + limit: ConduitNumber.Optional, + search: ConduitString.Optional, + }, + }, + new ConduitRouteReturnDefinition('GetEventRelays', { + relays: [EventRelay.name], + count: ConduitNumber.Required, + }), + this.eventRelayAdmin.listEventRelays.bind(this.eventRelayAdmin), + ); + this.routingManager.route( + { + path: '/event-relays/:id', + action: ConduitRouteActions.GET, + description: `Returns a single event-to-socket relay.`, + urlParams: { + id: ConduitString.Required, + }, + }, + new ConduitRouteReturnDefinition(EventRelay.name), + this.eventRelayAdmin.getEventRelay.bind(this.eventRelayAdmin), + ); + this.routingManager.route( + { + path: '/event-relays', + action: ConduitRouteActions.POST, + description: `Creates an event-to-socket relay. Clients subscribe on /events/ after a ReBAC check.`, + bodyParams: { + name: ConduitString.Required, + notes: ConduitString.Optional, + active: ConduitBoolean.Optional, + busEvent: ConduitString.Required, + socketEvent: ConduitString.Required, + resourceType: ConduitString.Required, + resourceIdPath: ConduitString.Required, + permission: ConduitString.Required, + messageTemplate: ConduitJson.Required, + }, + }, + new ConduitRouteReturnDefinition('CreateEventRelay', EventRelay.name), + this.eventRelayAdmin.createEventRelay.bind(this.eventRelayAdmin), + ); + this.routingManager.route( + { + path: '/event-relays/:id', + action: ConduitRouteActions.PATCH, + description: `Updates an event-to-socket relay.`, + urlParams: { + id: ConduitString.Required, + }, + bodyParams: { + name: ConduitString.Optional, + notes: ConduitString.Optional, + active: ConduitBoolean.Optional, + busEvent: ConduitString.Optional, + socketEvent: ConduitString.Optional, + resourceType: ConduitString.Optional, + resourceIdPath: ConduitString.Optional, + permission: ConduitString.Optional, + messageTemplate: ConduitJson.Optional, + }, + }, + new ConduitRouteReturnDefinition('PatchEventRelay', EventRelay.name), + this.eventRelayAdmin.patchEventRelay.bind(this.eventRelayAdmin), + ); + this.routingManager.route( + { + path: '/event-relays/:id', + action: ConduitRouteActions.DELETE, + description: `Deletes an event-to-socket relay.`, + urlParams: { + id: ConduitString.Required, + }, + }, + new ConduitRouteReturnDefinition('DeleteEventRelay', { + message: ConduitString.Required, + }), + this.eventRelayAdmin.deleteEventRelay.bind(this.eventRelayAdmin), + ); this.routingManager.registerRoutes(); } } diff --git a/modules/router/src/event-relays/EventRelayManager.ts b/modules/router/src/event-relays/EventRelayManager.ts new file mode 100644 index 000000000..bddae2127 --- /dev/null +++ b/modules/router/src/event-relays/EventRelayManager.ts @@ -0,0 +1,138 @@ +import { ConduitGrpcSdk } from '@conduitplatform/grpc-sdk'; +import { EventRelay } from '../models/index.js'; +import { + EVENT_RELAY_REFRESH_CHANNEL, + EVENT_RELAY_SUBSCRIBER_PREFIX, +} from './constants.js'; +import { groupRelaysByChannel, planChannelSubscriptions } from './channels.js'; +import { buildRelayEmissions, parseBusPayload } from './process.js'; +import { EventRelayPusher } from './push.js'; + +export type { EventRelayPusher } from './push.js'; +export { createEventRelayPusher } from './push.js'; + +export class EventRelayManager { + private readonly relaysByChannel = new Map(); + private readonly subscribedChannels = new Set(); + private started = false; + + constructor( + private readonly grpcSdk: ConduitGrpcSdk, + private readonly push: EventRelayPusher, + ) {} + + async start(): Promise { + if (!this.started) { + this.grpcSdk.bus?.subscribe( + EVENT_RELAY_REFRESH_CHANNEL, + () => { + this.reconcile().catch(err => { + ConduitGrpcSdk.Logger.error(err as Error); + }); + }, + 'router-event-relays-refresh', + ); + this.started = true; + } + await this.reconcile(); + } + + async stop(): Promise { + for (const channel of [...this.subscribedChannels]) { + this.grpcSdk.bus?.unsubscribe(`${EVENT_RELAY_SUBSCRIBER_PREFIX}${channel}`); + this.subscribedChannels.delete(channel); + } + this.relaysByChannel.clear(); + if (this.started) { + this.grpcSdk.bus?.unsubscribe('router-event-relays-refresh'); + this.started = false; + } + } + + async notifyChanged(): Promise { + if (this.started) { + await this.reconcile(); + } + this.grpcSdk.bus?.publish(EVENT_RELAY_REFRESH_CHANNEL, ''); + } + + async reconcile(): Promise { + const relays = await EventRelay.getInstance().findMany({ active: true }); + const next = groupRelaysByChannel(relays); + const { toSubscribe, toUnsubscribe } = planChannelSubscriptions( + this.subscribedChannels, + next.keys(), + ); + + this.relaysByChannel.clear(); + for (const [channel, list] of next) { + this.relaysByChannel.set(channel, list); + } + + for (const channel of toUnsubscribe) { + this.grpcSdk.bus?.unsubscribe(`${EVENT_RELAY_SUBSCRIBER_PREFIX}${channel}`); + this.subscribedChannels.delete(channel); + } + + for (const channel of toSubscribe) { + this.grpcSdk.bus?.subscribe( + channel, + message => this.onBusMessage(channel, message), + `${EVENT_RELAY_SUBSCRIBER_PREFIX}${channel}`, + ); + this.subscribedChannels.add(channel); + } + } + + getActiveRelay(id: string): EventRelay | undefined { + for (const relays of this.relaysByChannel.values()) { + const match = relays.find(relay => relay._id === id); + if (match) return match; + } + return undefined; + } + + private onBusMessage(channel: string, message: string): void { + const relays = this.relaysByChannel.get(channel); + if (!relays || relays.length === 0) { + return; + } + + let payload: unknown; + try { + payload = parseBusPayload(message); + } catch (err) { + ConduitGrpcSdk.Metrics?.increment('event_relays_failed_total'); + ConduitGrpcSdk.Logger.error( + `Event relay failed to parse payload for ${channel}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return; + } + + const { emissions, failures } = buildRelayEmissions(relays, payload); + for (const failure of failures) { + ConduitGrpcSdk.Metrics?.increment('event_relays_failed_total'); + ConduitGrpcSdk.Logger.warn( + `Event relay ${failure.relayId} skipped on ${failure.busEvent}: ${failure.reason}`, + ); + } + + for (const emission of emissions) { + this.push(emission.socketEvent, emission.data, [emission.room]).then( + () => { + ConduitGrpcSdk.Metrics?.increment('event_relays_emitted_total'); + }, + err => { + ConduitGrpcSdk.Metrics?.increment('event_relays_failed_total'); + ConduitGrpcSdk.Logger.error( + `Event relay ${emission.relayId} emit failed on ${channel}: ${ + (err as Error).message + }`, + ); + }, + ); + } + } +} diff --git a/modules/router/src/event-relays/EventRelaySockets.test.ts b/modules/router/src/event-relays/EventRelaySockets.test.ts new file mode 100644 index 000000000..19f455b7a --- /dev/null +++ b/modules/router/src/event-relays/EventRelaySockets.test.ts @@ -0,0 +1,91 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { status } from '@grpc/grpc-js'; +import { authorizeRelaySubscription } from './authorize.js'; +import { eventRelayRoom } from './rooms.js'; +import type { RelayLookup } from './authorize.js'; + +function mockSdk(options: { + available?: boolean; + allow?: boolean; + throwOnCan?: boolean; +}) { + return { + isAvailable: () => options.available !== false, + authorization: + options.available === false + ? null + : { + can: async () => { + if (options.throwOnCan) { + throw new Error('authz down'); + } + return { allow: options.allow === true }; + }, + }, + }; +} + +function mockManager(relay: unknown): RelayLookup { + return { + getActiveRelay: () => relay as ReturnType, + }; +} + +const relay = { + _id: 'relay-1', + permission: 'read', + resourceType: 'Order', +}; + +describe('authorizeRelaySubscription', () => { + it('returns the deterministic room when ReBAC allows', async () => { + const room = await authorizeRelaySubscription( + mockSdk({ allow: true }), + mockManager(relay), + 'user-1', + 'relay-1', + 'order-1', + ); + assert.equal(room, eventRelayRoom('relay-1', 'order-1')); + }); + + it('denies when ReBAC returns false', async () => { + await assert.rejects( + () => + authorizeRelaySubscription( + mockSdk({ allow: false }), + mockManager(relay), + 'user-1', + 'relay-1', + 'order-1', + ), + (err: any) => err.code === status.PERMISSION_DENIED, + ); + }); + + it('fails closed when authorization is unavailable', async () => { + await assert.rejects( + () => + authorizeRelaySubscription( + mockSdk({ available: false }), + mockManager(relay), + 'user-1', + 'relay-1', + 'order-1', + ), + (err: any) => err.code === status.UNAVAILABLE, + ); + }); + + it('unsubscribe room matches subscribe room for the same ids', async () => { + const subscribed = await authorizeRelaySubscription( + mockSdk({ allow: true }), + mockManager(relay), + 'user-1', + 'relay-1', + 'order-1', + ); + assert.equal(subscribed, eventRelayRoom('relay-1', 'order-1')); + }); +}); diff --git a/modules/router/src/event-relays/EventRelaySockets.ts b/modules/router/src/event-relays/EventRelaySockets.ts new file mode 100644 index 000000000..32e5fec2a --- /dev/null +++ b/modules/router/src/event-relays/EventRelaySockets.ts @@ -0,0 +1,96 @@ +import { status } from '@grpc/grpc-js'; +import { ConduitGrpcSdk, GrpcError, TYPE } from '@conduitplatform/grpc-sdk'; +import { ConduitSocket, ConduitSocketEvent } from '@conduitplatform/hermes'; +import { EVENTS_NAMESPACE } from './constants.js'; +import { EventRelayManager } from './EventRelayManager.js'; +import { eventRelayRoom } from './rooms.js'; +import { validateResourceId } from './validation.js'; +import { authorizeRelaySubscription, toSubscriptionError } from './authorize.js'; + +export function createEventsSocket( + grpcSdk: ConduitGrpcSdk, + manager: EventRelayManager, +): ConduitSocket { + const events = new Map(); + + events.set('connect', { + name: 'connect', + handler: async () => ({ event: 'join-room', rooms: [] }), + }); + + events.set('disconnect', { + name: 'disconnect', + handler: async () => ({ event: 'leave-room', rooms: [] }), + }); + + events.set('subscribe', { + name: 'subscribe', + params: [TYPE.String, TYPE.String], + handler: async request => { + const userId = request.context?.user?._id as string | undefined; + const [relayId, resourceId] = request.params ?? []; + const room = await authorizeOrThrow(grpcSdk, manager, userId, relayId, resourceId); + return { event: 'join-room', rooms: [room] }; + }, + }); + + events.set('unsubscribe', { + name: 'unsubscribe', + params: [TYPE.String, TYPE.String], + handler: async request => { + const [relayId, resourceId] = request.params ?? []; + if (typeof relayId !== 'string' || relayId.trim() === '') { + throw new GrpcError(status.INVALID_ARGUMENT, 'Relay ID is required'); + } + try { + const validatedResourceId = validateResourceId(resourceId); + return { + event: 'leave-room', + rooms: [eventRelayRoom(relayId, validatedResourceId)], + }; + } catch (err) { + throw toGrpcError(err); + } + }, + }); + + return new ConduitSocket( + { + path: EVENTS_NAMESPACE, + name: 'eventRelays', + description: 'Declarative bus-to-socket event relays', + middlewares: ['authMiddleware'], + }, + events, + ); +} + +async function authorizeOrThrow( + grpcSdk: ConduitGrpcSdk, + manager: EventRelayManager, + userId: string | undefined, + relayId: unknown, + resourceId: unknown, +): Promise { + try { + return await authorizeRelaySubscription( + grpcSdk, + manager, + userId, + relayId, + resourceId, + () => { + ConduitGrpcSdk.Metrics?.increment('event_relay_subscriptions_denied_total'); + }, + ); + } catch (err) { + throw toGrpcError(err); + } +} + +function toGrpcError(err: unknown): GrpcError { + const mapped = toSubscriptionError(err); + return new GrpcError(mapped.code, mapped.message); +} + +export { authorizeRelaySubscription } from './authorize.js'; diff --git a/modules/router/src/event-relays/authorize.ts b/modules/router/src/event-relays/authorize.ts new file mode 100644 index 000000000..852cf5595 --- /dev/null +++ b/modules/router/src/event-relays/authorize.ts @@ -0,0 +1,101 @@ +import { status } from '@grpc/grpc-js'; +import { eventRelayRoom } from './rooms.js'; +import { validateResourceId } from './validation.js'; +import { EventRelayValidationError } from './validationError.js'; + +export class RelaySubscriptionError extends Error { + constructor( + readonly code: number, + message: string, + ) { + super(message); + this.name = 'RelaySubscriptionError'; + } +} + +export type RelaySubscriptionTarget = { + _id: string; + permission: string; + resourceType: string; +}; + +export type RelayAuthorizationSdk = { + isAvailable: (module: string) => boolean; + authorization?: { + can: (request: { + subject: string; + actions: string[]; + resource: string; + }) => Promise<{ allow: boolean }>; + } | null; +}; + +export type RelayLookup = { + getActiveRelay(id: string): RelaySubscriptionTarget | undefined; +}; + +export async function authorizeRelaySubscription( + grpcSdk: RelayAuthorizationSdk, + manager: RelayLookup, + userId: string | undefined, + relayId: unknown, + resourceId: unknown, + onDenied?: () => void, +): Promise { + if (!userId) { + throw new RelaySubscriptionError(status.UNAUTHENTICATED, 'Authentication required'); + } + if (typeof relayId !== 'string' || relayId.trim() === '') { + throw new RelaySubscriptionError(status.INVALID_ARGUMENT, 'Relay ID is required'); + } + + let validatedResourceId: string; + try { + validatedResourceId = validateResourceId(resourceId); + } catch (err) { + throw toSubscriptionError(err); + } + + const relay = manager.getActiveRelay(relayId); + if (!relay) { + throw new RelaySubscriptionError(status.NOT_FOUND, 'Event relay not found'); + } + + if (!grpcSdk.authorization || !grpcSdk.isAvailable('authorization')) { + onDenied?.(); + throw new RelaySubscriptionError(status.UNAVAILABLE, 'Authorization is unavailable'); + } + + let allowed = false; + try { + const decision = await grpcSdk.authorization.can({ + subject: `User:${userId}`, + actions: [relay.permission], + resource: `${relay.resourceType}:${validatedResourceId}`, + }); + allowed = decision.allow; + } catch { + onDenied?.(); + throw new RelaySubscriptionError(status.UNAVAILABLE, 'Authorization check failed'); + } + + if (!allowed) { + onDenied?.(); + throw new RelaySubscriptionError(status.PERMISSION_DENIED, 'Permission denied'); + } + + return eventRelayRoom(relay._id, validatedResourceId); +} + +export function toSubscriptionError(err: unknown): RelaySubscriptionError { + if (err instanceof EventRelayValidationError) { + return new RelaySubscriptionError(status.INVALID_ARGUMENT, err.message); + } + if (err instanceof RelaySubscriptionError) { + return err; + } + return new RelaySubscriptionError( + status.INTERNAL, + err instanceof Error ? err.message : 'Unexpected error', + ); +} diff --git a/modules/router/src/event-relays/channels.test.ts b/modules/router/src/event-relays/channels.test.ts new file mode 100644 index 000000000..9870d0bd9 --- /dev/null +++ b/modules/router/src/event-relays/channels.test.ts @@ -0,0 +1,32 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { groupRelaysByChannel, planChannelSubscriptions } from './channels.js'; + +describe('groupRelaysByChannel', () => { + it('groups multiple relays onto the same exact channel', () => { + const grouped = groupRelaysByChannel([ + { _id: 'a', busEvent: 'orders.paid' }, + { _id: 'b', busEvent: 'orders.paid' }, + { _id: 'c', busEvent: 'orders.shipped' }, + ]); + assert.equal(grouped.get('orders.paid')?.length, 2); + assert.equal(grouped.get('orders.shipped')?.length, 1); + }); +}); + +describe('planChannelSubscriptions', () => { + it('subscribes new channels and unsubscribes removed ones', () => { + const plan = planChannelSubscriptions( + ['orders.paid', 'stale.channel'], + ['orders.paid', 'orders.shipped'], + ); + assert.deepEqual(plan.toSubscribe, ['orders.shipped']); + assert.deepEqual(plan.toUnsubscribe, ['stale.channel']); + }); + + it('is a no-op when the channel set is unchanged', () => { + const plan = planChannelSubscriptions(['orders.paid'], ['orders.paid']); + assert.deepEqual(plan.toSubscribe, []); + assert.deepEqual(plan.toUnsubscribe, []); + }); +}); diff --git a/modules/router/src/event-relays/channels.ts b/modules/router/src/event-relays/channels.ts new file mode 100644 index 000000000..c6ae3d692 --- /dev/null +++ b/modules/router/src/event-relays/channels.ts @@ -0,0 +1,23 @@ +export function groupRelaysByChannel( + relays: T[], +): Map { + const next = new Map(); + for (const relay of relays) { + const list = next.get(relay.busEvent) ?? []; + list.push(relay); + next.set(relay.busEvent, list); + } + return next; +} + +export function planChannelSubscriptions( + currentlySubscribed: Iterable, + nextChannels: Iterable, +): { toSubscribe: string[]; toUnsubscribe: string[] } { + const current = new Set(currentlySubscribed); + const next = new Set(nextChannels); + return { + toUnsubscribe: [...current].filter(channel => !next.has(channel)), + toSubscribe: [...next].filter(channel => !current.has(channel)), + }; +} diff --git a/modules/router/src/event-relays/constants.ts b/modules/router/src/event-relays/constants.ts new file mode 100644 index 000000000..e283da0dd --- /dev/null +++ b/modules/router/src/event-relays/constants.ts @@ -0,0 +1,32 @@ +export const EVENTS_NAMESPACE = '/events/'; +export const EVENT_RELAY_REFRESH_CHANNEL = 'router:event-relays:refresh'; +export const EVENT_RELAY_SUBSCRIBER_PREFIX = 'event-relay:'; + +export const MAX_TEMPLATE_BYTES = 16 * 1024; +export const MAX_OUTPUT_BYTES = 64 * 1024; +export const MAX_TEMPLATE_DEPTH = 10; +export const MAX_PATH_SEGMENTS = 8; +export const MAX_NAME_LENGTH = 64; +export const MAX_DESCRIPTION_LENGTH = 256; +export const MAX_BUS_EVENT_LENGTH = 128; +export const MAX_SOCKET_EVENT_LENGTH = 64; +export const MAX_RESOURCE_TYPE_LENGTH = 64; +export const MAX_RESOURCE_ID_LENGTH = 128; +export const MAX_RESOURCE_ID_PATH_LENGTH = 128; +export const MAX_PERMISSION_LENGTH = 64; + +export const FORBIDDEN_PATH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']); + +export const RESERVED_SOCKET_EVENTS = new Set([ + 'connect', + 'disconnect', + 'connect_error', + 'error', + 'join-room', + 'leave-room', + 'conduit_error', + 'subscribe', + 'unsubscribe', + 'ping', + 'pong', +]); diff --git a/modules/router/src/event-relays/index.ts b/modules/router/src/event-relays/index.ts new file mode 100644 index 000000000..caa9a4091 --- /dev/null +++ b/modules/router/src/event-relays/index.ts @@ -0,0 +1,25 @@ +export { EventRelayValidationError } from './validationError.js'; +export { + EVENTS_NAMESPACE, + EVENT_RELAY_REFRESH_CHANNEL, + RESERVED_SOCKET_EVENTS, +} from './constants.js'; +export { lookupOwnPath, parseDotPath, requireOwnPath } from './path.js'; +export { renderMessageTemplate, assertTemplateSize } from './template.js'; +export { + validateEventRelayInput, + validateResourceId, + type EventRelayInput, +} from './validation.js'; +export { eventRelayRoom } from './rooms.js'; +export { + parseBusPayload, + buildRelayEmissions, + type RelayEmission, + type ProcessResult, +} from './process.js'; +export { EventRelayManager } from './EventRelayManager.js'; +export { createEventRelayPusher } from './push.js'; +export { createEventsSocket, authorizeRelaySubscription } from './EventRelaySockets.js'; +export { buildSearchQuery, parsePagination } from './search.js'; +export { groupRelaysByChannel, planChannelSubscriptions } from './channels.js'; diff --git a/modules/router/src/event-relays/path.test.ts b/modules/router/src/event-relays/path.test.ts new file mode 100644 index 000000000..ecd1b0f2b --- /dev/null +++ b/modules/router/src/event-relays/path.test.ts @@ -0,0 +1,39 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { lookupOwnPath, parseDotPath, requireOwnPath } from './path.js'; + +describe('parseDotPath', () => { + it('accepts safe own-property paths', () => { + assert.deepEqual(parseDotPath('_id', 'Path'), ['_id']); + assert.deepEqual(parseDotPath('document.ownerId', 'Path'), ['document', 'ownerId']); + }); + + it('rejects prototype pollution segments', () => { + assert.throws(() => parseDotPath('__proto__.polluted', 'Path'), { + name: 'EventRelayValidationError', + }); + assert.throws(() => parseDotPath('constructor.prototype', 'Path'), { + name: 'EventRelayValidationError', + }); + }); +}); + +describe('lookupOwnPath', () => { + it('reads own properties only', () => { + const payload = { document: { _id: 'abc' } }; + assert.equal(lookupOwnPath(payload, 'document._id'), 'abc'); + }); + + it('does not follow inherited properties', () => { + const payload = Object.create({ leaked: 'nope' }); + payload.own = 'yes'; + assert.equal(lookupOwnPath(payload, 'own'), 'yes'); + assert.equal(lookupOwnPath(payload, 'leaked'), undefined); + }); + + it('requireOwnPath fails closed on missing fields', () => { + assert.throws(() => requireOwnPath({ a: 1 }, 'b', 'Resource ID path'), { + name: 'EventRelayValidationError', + }); + }); +}); diff --git a/modules/router/src/event-relays/path.ts b/modules/router/src/event-relays/path.ts new file mode 100644 index 000000000..e4ba89d7c --- /dev/null +++ b/modules/router/src/event-relays/path.ts @@ -0,0 +1,49 @@ +import { FORBIDDEN_PATH_SEGMENTS, MAX_PATH_SEGMENTS } from './constants.js'; +import { EventRelayValidationError } from './validationError.js'; + +const PATH_SEGMENT = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export function parseDotPath(path: string, label: string): string[] { + const trimmed = path.trim(); + if (!trimmed) { + throw new EventRelayValidationError(`${label} is required`); + } + const segments = trimmed.split('.'); + if (segments.length > MAX_PATH_SEGMENTS) { + throw new EventRelayValidationError(`${label} exceeds ${MAX_PATH_SEGMENTS} segments`); + } + for (const segment of segments) { + if (FORBIDDEN_PATH_SEGMENTS.has(segment) || !PATH_SEGMENT.test(segment)) { + throw new EventRelayValidationError(`${label} contains an invalid path segment`); + } + } + return segments; +} + +export function lookupOwnPath(source: unknown, path: string): unknown { + const segments = parseDotPath(path, 'Path'); + let current: unknown = source; + for (const segment of segments) { + if (current === null || typeof current !== 'object') { + return undefined; + } + if (FORBIDDEN_PATH_SEGMENTS.has(segment)) { + return undefined; + } + if (!Object.prototype.hasOwnProperty.call(current, segment)) { + return undefined; + } + current = (current as Record)[segment]; + } + return current; +} + +export function requireOwnPath(source: unknown, path: string, label: string): unknown { + const value = lookupOwnPath(source, path); + if (value === undefined) { + throw new EventRelayValidationError( + `${label} '${path}' was not found on the payload`, + ); + } + return value; +} diff --git a/modules/router/src/event-relays/process.test.ts b/modules/router/src/event-relays/process.test.ts new file mode 100644 index 000000000..cf3c1c10b --- /dev/null +++ b/modules/router/src/event-relays/process.test.ts @@ -0,0 +1,85 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildRelayEmissions, parseBusPayload } from './process.js'; +import { createEventRelayPusher } from './push.js'; +import { eventRelayRoom } from './rooms.js'; +import { EVENTS_NAMESPACE } from './constants.js'; + +const relayA = { + _id: 'relay-a', + busEvent: 'database:update:Order', + socketEvent: 'order-updated', + resourceIdPath: '_id', + messageTemplate: { id: '{{payload._id}}', status: '{{payload.status}}' }, +}; + +const relayB = { + _id: 'relay-b', + busEvent: 'database:update:Order', + socketEvent: 'order-paid', + resourceIdPath: '_id', + messageTemplate: { paid: '{{payload.status}}' }, +}; + +describe('parseBusPayload', () => { + it('parses JSON once', () => { + assert.deepEqual(parseBusPayload('{"_id":"1"}'), { _id: '1' }); + }); + + it('fails closed on malformed JSON', () => { + assert.throws(() => parseBusPayload('{'), { name: 'EventRelayValidationError' }); + assert.throws(() => parseBusPayload(''), { name: 'EventRelayValidationError' }); + }); +}); + +describe('buildRelayEmissions', () => { + it('emits to every mapping on the same channel', () => { + const result = buildRelayEmissions([relayA, relayB], { + _id: 'order-1', + status: 'paid', + }); + assert.equal(result.failures.length, 0); + assert.equal(result.emissions.length, 2); + assert.deepEqual( + result.emissions.map(item => item.socketEvent), + ['order-updated', 'order-paid'], + ); + assert.equal(result.emissions[0].room, eventRelayRoom('relay-a', 'order-1')); + }); + + it('records per-relay failures without dropping siblings', () => { + const result = buildRelayEmissions( + [ + relayA, + { + ...relayB, + resourceIdPath: 'missing', + }, + ], + { _id: 'order-1', status: 'paid' }, + ); + assert.equal(result.emissions.length, 1); + assert.equal(result.failures.length, 1); + assert.equal(result.failures[0].relayId, 'relay-b'); + }); +}); + +describe('createEventRelayPusher', () => { + it('pushes locally to /events/ rooms so HA instances do not duplicate', async () => { + const calls: unknown[] = []; + const push = createEventRelayPusher(async data => { + calls.push(data); + }); + await push('order-updated', { id: '1' }, ['room-1']); + assert.deepEqual(calls, [ + { + event: 'order-updated', + data: { id: '1' }, + receivers: [], + rooms: ['room-1'], + namespace: EVENTS_NAMESPACE, + localOnly: true, + }, + ]); + }); +}); diff --git a/modules/router/src/event-relays/process.ts b/modules/router/src/event-relays/process.ts new file mode 100644 index 000000000..858c9f6d0 --- /dev/null +++ b/modules/router/src/event-relays/process.ts @@ -0,0 +1,75 @@ +import { requireOwnPath } from './path.js'; +import { eventRelayRoom } from './rooms.js'; +import { renderMessageTemplate } from './template.js'; +import { validateResourceId } from './validation.js'; +import { EventRelayValidationError } from './validationError.js'; + +export type RelayProcessInput = { + _id: string; + busEvent: string; + socketEvent: string; + resourceIdPath: string; + messageTemplate: unknown; +}; + +export type RelayEmission = { + relayId: string; + busEvent: string; + socketEvent: string; + room: string; + data: unknown; +}; + +export type RelayFailure = { + relayId: string; + busEvent: string; + reason: string; +}; + +export type ProcessResult = { + emissions: RelayEmission[]; + failures: RelayFailure[]; +}; + +export function parseBusPayload(rawMessage: string): unknown { + if (typeof rawMessage !== 'string' || rawMessage.trim() === '') { + throw new EventRelayValidationError('Bus payload is empty'); + } + try { + return JSON.parse(rawMessage); + } catch { + throw new EventRelayValidationError('Bus payload is not valid JSON'); + } +} + +export function buildRelayEmissions( + relays: RelayProcessInput[], + payload: unknown, +): ProcessResult { + const emissions: RelayEmission[] = []; + const failures: RelayFailure[] = []; + + for (const relay of relays) { + try { + const resourceId = validateResourceId( + requireOwnPath(payload, relay.resourceIdPath, 'Resource ID path'), + ); + const data = renderMessageTemplate(relay.messageTemplate, payload); + emissions.push({ + relayId: relay._id, + busEvent: relay.busEvent, + socketEvent: relay.socketEvent, + room: eventRelayRoom(relay._id, resourceId), + data, + }); + } catch (err) { + failures.push({ + relayId: relay._id, + busEvent: relay.busEvent, + reason: err instanceof Error ? err.message : String(err), + }); + } + } + + return { emissions, failures }; +} diff --git a/modules/router/src/event-relays/push.ts b/modules/router/src/event-relays/push.ts new file mode 100644 index 000000000..79d9911b2 --- /dev/null +++ b/modules/router/src/event-relays/push.ts @@ -0,0 +1,28 @@ +import { EVENTS_NAMESPACE } from './constants.js'; + +export type EventRelayPusher = ( + event: string, + data: unknown, + rooms: string[], +) => Promise; + +export type SocketPushFn = (data: { + event: string; + data?: unknown; + receivers: string[]; + rooms: string[]; + namespace: string; + localOnly?: boolean; +}) => Promise; + +export function createEventRelayPusher(socketPush: SocketPushFn): EventRelayPusher { + return (event, data, rooms) => + socketPush({ + event, + data, + receivers: [], + rooms, + namespace: EVENTS_NAMESPACE, + localOnly: true, + }); +} diff --git a/modules/router/src/event-relays/rooms.test.ts b/modules/router/src/event-relays/rooms.test.ts new file mode 100644 index 000000000..e73b19a77 --- /dev/null +++ b/modules/router/src/event-relays/rooms.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { eventRelayRoom } from './rooms.js'; + +describe('eventRelayRoom', () => { + it('is deterministic for the same relay and resource', () => { + assert.equal( + eventRelayRoom('relay-1', 'resource-a'), + eventRelayRoom('relay-1', 'resource-a'), + ); + }); + + it('changes when the resource or relay changes', () => { + assert.notEqual( + eventRelayRoom('relay-1', 'resource-a'), + eventRelayRoom('relay-1', 'resource-b'), + ); + assert.notEqual( + eventRelayRoom('relay-1', 'resource-a'), + eventRelayRoom('relay-2', 'resource-a'), + ); + }); + + it('does not embed the raw resource id', () => { + const room = eventRelayRoom('relay-1', 'secret-resource'); + assert.equal(room.includes('secret-resource'), false); + assert.match(room, /^er:relay-1:[a-f0-9]{64}$/); + }); +}); diff --git a/modules/router/src/event-relays/rooms.ts b/modules/router/src/event-relays/rooms.ts new file mode 100644 index 000000000..fd5dd54ec --- /dev/null +++ b/modules/router/src/event-relays/rooms.ts @@ -0,0 +1,6 @@ +import { createHash } from 'node:crypto'; + +export function eventRelayRoom(relayId: string, resourceId: string): string { + const digest = createHash('sha256').update(resourceId).digest('hex'); + return `er:${relayId}:${digest}`; +} diff --git a/modules/router/src/event-relays/search.test.ts b/modules/router/src/event-relays/search.test.ts new file mode 100644 index 000000000..308ea2890 --- /dev/null +++ b/modules/router/src/event-relays/search.test.ts @@ -0,0 +1,34 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildSearchQuery, parsePagination } from './search.js'; + +describe('parsePagination', () => { + it('defaults skip and limit for the admin list', () => { + assert.deepEqual(parsePagination(), { skip: 0, limit: 25 }); + assert.deepEqual(parsePagination(10, 5), { skip: 10, limit: 5 }); + }); +}); + +describe('buildSearchQuery', () => { + it('returns an empty query without search', () => { + assert.deepEqual(buildSearchQuery(), {}); + assert.deepEqual(buildSearchQuery(''), {}); + }); + + it('looks up ObjectIds exactly', () => { + assert.deepEqual(buildSearchQuery('507f1f77bcf86cd799439011'), { + _id: '507f1f77bcf86cd799439011', + }); + }); + + it('escapes regex metacharacters in name/channel search', () => { + const query = buildSearchQuery('order.paid*'); + assert.deepEqual(query, { + $or: [ + { name: { $regex: '.*order\\.paid\\*.*', $options: 'i' } }, + { busEvent: { $regex: '.*order\\.paid\\*.*', $options: 'i' } }, + { socketEvent: { $regex: '.*order\\.paid\\*.*', $options: 'i' } }, + ], + }); + }); +}); diff --git a/modules/router/src/event-relays/search.ts b/modules/router/src/event-relays/search.ts new file mode 100644 index 000000000..c336aa363 --- /dev/null +++ b/modules/router/src/event-relays/search.ts @@ -0,0 +1,26 @@ +export function parsePagination( + skip?: number, + limit?: number, +): { skip: number; limit: number } { + return { + skip: skip ?? 0, + limit: limit ?? 25, + }; +} + +export function buildSearchQuery(search?: string | null): Record { + if (search == null || search === '') { + return {}; + } + if (/^[a-fA-F\d]{24}$/.test(search)) { + return { _id: search }; + } + const identifier = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return { + $or: [ + { name: { $regex: `.*${identifier}.*`, $options: 'i' } }, + { busEvent: { $regex: `.*${identifier}.*`, $options: 'i' } }, + { socketEvent: { $regex: `.*${identifier}.*`, $options: 'i' } }, + ], + }; +} diff --git a/modules/router/src/event-relays/template.test.ts b/modules/router/src/event-relays/template.test.ts new file mode 100644 index 000000000..7959a08bd --- /dev/null +++ b/modules/router/src/event-relays/template.test.ts @@ -0,0 +1,39 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { renderMessageTemplate } from './template.js'; + +describe('renderMessageTemplate', () => { + it('substitutes exact placeholders with the raw JSON value', () => { + const rendered = renderMessageTemplate( + { id: '{{payload._id}}', count: '{{payload.count}}' }, + { _id: 'abc', count: 3 }, + ); + assert.deepEqual(rendered, { id: 'abc', count: 3 }); + }); + + it('interpolates placeholders inside strings', () => { + const rendered = renderMessageTemplate( + { label: 'Order {{payload.status}}' }, + { status: 'paid' }, + ); + assert.deepEqual(rendered, { label: 'Order paid' }); + }); + + it('fails closed when a placeholder is missing', () => { + assert.throws( + () => renderMessageTemplate({ id: '{{payload.missing}}' }, { _id: 'abc' }), + { name: 'EventRelayValidationError' }, + ); + }); + + it('rejects prototype paths in placeholders', () => { + assert.throws( + () => + renderMessageTemplate( + { hack: '{{payload.__proto__.polluted}}' }, + { __proto__: { polluted: true } }, + ), + { name: 'EventRelayValidationError' }, + ); + }); +}); diff --git a/modules/router/src/event-relays/template.ts b/modules/router/src/event-relays/template.ts new file mode 100644 index 000000000..f0f70dcc9 --- /dev/null +++ b/modules/router/src/event-relays/template.ts @@ -0,0 +1,136 @@ +import { MAX_OUTPUT_BYTES, MAX_TEMPLATE_BYTES, MAX_TEMPLATE_DEPTH } from './constants.js'; +import { lookupOwnPath, parseDotPath } from './path.js'; +import { EventRelayValidationError } from './validationError.js'; + +const PLACEHOLDER = /\{\{\s*payload\.([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}/g; +const EXACT_PLACEHOLDER = /^\{\{\s*payload\.([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}$/; + +export function assertTemplateSize(template: unknown): void { + let serialized: string; + try { + serialized = JSON.stringify(template); + } catch { + throw new EventRelayValidationError('Message template must be valid JSON'); + } + if (serialized === undefined) { + throw new EventRelayValidationError('Message template must be valid JSON'); + } + if (Buffer.byteLength(serialized, 'utf8') > MAX_TEMPLATE_BYTES) { + throw new EventRelayValidationError( + `Message template exceeds ${MAX_TEMPLATE_BYTES} bytes`, + ); + } + walkTemplate(template, 0, (value, depth) => { + if (depth > MAX_TEMPLATE_DEPTH) { + throw new EventRelayValidationError( + `Message template exceeds ${MAX_TEMPLATE_DEPTH} nested levels`, + ); + } + if (typeof value === 'string') { + validatePlaceholders(value); + } + }); +} + +export function renderMessageTemplate(template: unknown, payload: unknown): unknown { + assertTemplateSize(template); + const rendered = renderValue(template, payload, 0); + const serialized = JSON.stringify(rendered); + if (Buffer.byteLength(serialized, 'utf8') > MAX_OUTPUT_BYTES) { + throw new EventRelayValidationError( + `Rendered message exceeds ${MAX_OUTPUT_BYTES} bytes`, + ); + } + return rendered; +} + +function renderValue(value: unknown, payload: unknown, depth: number): unknown { + if (depth > MAX_TEMPLATE_DEPTH) { + throw new EventRelayValidationError( + `Message template exceeds ${MAX_TEMPLATE_DEPTH} nested levels`, + ); + } + if (typeof value === 'string') { + return interpolateString(value, payload); + } + if (Array.isArray(value)) { + return value.map(item => renderValue(item, payload, depth + 1)); + } + if (value !== null && typeof value === 'object') { + const output: Record = {}; + for (const key of Object.keys(value as Record)) { + output[key] = renderValue( + (value as Record)[key], + payload, + depth + 1, + ); + } + return output; + } + return value; +} + +function interpolateString(value: string, payload: unknown): unknown { + const exact = value.trim().match(EXACT_PLACEHOLDER); + if (exact) { + parseDotPath(exact[1], 'Placeholder path'); + const resolved = lookupOwnPath(payload, exact[1]); + if (resolved === undefined) { + throw new EventRelayValidationError( + `Placeholder payload.${exact[1]} was not found on the payload`, + ); + } + return resolved; + } + + return value.replace(PLACEHOLDER, (_match, path: string) => { + parseDotPath(path, 'Placeholder path'); + const resolved = lookupOwnPath(payload, path); + if (resolved === undefined) { + throw new EventRelayValidationError( + `Placeholder payload.${path} was not found on the payload`, + ); + } + return stringifyPlaceholder(resolved); + }); +} + +function stringifyPlaceholder(value: unknown): string { + if (value === null || typeof value === 'string' || typeof value === 'number') { + return String(value); + } + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + return JSON.stringify(value); +} + +function validatePlaceholders(value: string): void { + const exact = value.trim().match(EXACT_PLACEHOLDER); + if (exact) { + parseDotPath(exact[1], 'Placeholder path'); + return; + } + for (const match of value.matchAll(PLACEHOLDER)) { + parseDotPath(match[1], 'Placeholder path'); + } +} + +function walkTemplate( + value: unknown, + depth: number, + visit: (value: unknown, depth: number) => void, +): void { + visit(value, depth); + if (Array.isArray(value)) { + for (const item of value) { + walkTemplate(item, depth + 1, visit); + } + return; + } + if (value !== null && typeof value === 'object') { + for (const nested of Object.values(value as Record)) { + walkTemplate(nested, depth + 1, visit); + } + } +} diff --git a/modules/router/src/event-relays/validation.test.ts b/modules/router/src/event-relays/validation.test.ts new file mode 100644 index 000000000..d8337a5f1 --- /dev/null +++ b/modules/router/src/event-relays/validation.test.ts @@ -0,0 +1,69 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { validateEventRelayInput, validateResourceId } from './validation.js'; + +const validInput = { + name: 'Order paid', + busEvent: 'database:update:Order', + socketEvent: 'order-updated', + resourceType: 'Order', + resourceIdPath: '_id', + permission: 'read', + messageTemplate: { id: '{{payload._id}}' }, +}; + +describe('validateEventRelayInput', () => { + it('accepts a complete relay definition', () => { + const parsed = validateEventRelayInput(validInput); + assert.equal(parsed.active, true); + assert.equal(parsed.busEvent, 'database:update:Order'); + }); + + it('rejects wildcard bus channels', () => { + assert.throws( + () => validateEventRelayInput({ ...validInput, busEvent: 'database:update:*' }), + { name: 'EventRelayValidationError' }, + ); + }); + + it('rejects reserved socket events', () => { + assert.throws( + () => validateEventRelayInput({ ...validInput, socketEvent: 'subscribe' }), + { name: 'EventRelayValidationError' }, + ); + assert.throws( + () => validateEventRelayInput({ ...validInput, socketEvent: 'join-room' }), + { name: 'EventRelayValidationError' }, + ); + }); + + it('rejects empty required fields', () => { + assert.throws(() => validateEventRelayInput({ ...validInput, name: '' }), { + name: 'EventRelayValidationError', + }); + assert.throws( + () => validateEventRelayInput({ ...validInput, messageTemplate: undefined }), + { name: 'EventRelayValidationError' }, + ); + }); +}); + +describe('validateResourceId', () => { + it('accepts object ids and uuids', () => { + assert.equal( + validateResourceId('507f1f77bcf86cd799439011'), + '507f1f77bcf86cd799439011', + ); + assert.equal( + validateResourceId('3b2c1a90-1111-2222-3333-444444444444'), + '3b2c1a90-1111-2222-3333-444444444444', + ); + }); + + it('rejects empty or colon-containing identifiers', () => { + assert.throws(() => validateResourceId(''), { name: 'EventRelayValidationError' }); + assert.throws(() => validateResourceId('Team:123'), { + name: 'EventRelayValidationError', + }); + }); +}); diff --git a/modules/router/src/event-relays/validation.ts b/modules/router/src/event-relays/validation.ts new file mode 100644 index 000000000..d6c6c5eb6 --- /dev/null +++ b/modules/router/src/event-relays/validation.ts @@ -0,0 +1,130 @@ +import { + MAX_BUS_EVENT_LENGTH, + MAX_DESCRIPTION_LENGTH, + MAX_NAME_LENGTH, + MAX_PERMISSION_LENGTH, + MAX_RESOURCE_ID_LENGTH, + MAX_RESOURCE_ID_PATH_LENGTH, + MAX_RESOURCE_TYPE_LENGTH, + MAX_SOCKET_EVENT_LENGTH, + RESERVED_SOCKET_EVENTS, +} from './constants.js'; +import { parseDotPath } from './path.js'; +import { assertTemplateSize } from './template.js'; +import { EventRelayValidationError } from './validationError.js'; + +export type EventRelayInput = { + name: string; + notes?: string; + active?: boolean; + busEvent: string; + socketEvent: string; + resourceType: string; + resourceIdPath: string; + permission: string; + messageTemplate: unknown; +}; + +const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$/; +const BUS_EVENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/; +const SOCKET_EVENT_PATTERN = /^[A-Za-z][A-Za-z0-9_:-]{0,63}$/; +const RESOURCE_TYPE_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,63}$/; +const PERMISSION_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,63}$/; +const RESOURCE_ID_PATTERN = /^[^\s:]{1,128}$/; + +export function validateEventRelayInput(input: EventRelayInput): EventRelayInput { + const name = requireTrimmed(input.name, 'Name'); + if (name.length > MAX_NAME_LENGTH || !NAME_PATTERN.test(name)) { + throw new EventRelayValidationError( + 'Name must be 1-64 characters and start with a letter or number', + ); + } + + const notes = + input.notes === undefined || input.notes === '' + ? undefined + : requireTrimmed(input.notes, 'Notes'); + if (notes && notes.length > MAX_DESCRIPTION_LENGTH) { + throw new EventRelayValidationError( + `Notes must be at most ${MAX_DESCRIPTION_LENGTH} characters`, + ); + } + + const busEvent = requireTrimmed(input.busEvent, 'Bus event'); + if ( + busEvent.length > MAX_BUS_EVENT_LENGTH || + busEvent.includes('*') || + !BUS_EVENT_PATTERN.test(busEvent) + ) { + throw new EventRelayValidationError( + 'Bus event must be an exact channel name with no wildcards', + ); + } + + const socketEvent = requireTrimmed(input.socketEvent, 'Socket event'); + if ( + socketEvent.length > MAX_SOCKET_EVENT_LENGTH || + !SOCKET_EVENT_PATTERN.test(socketEvent) || + RESERVED_SOCKET_EVENTS.has(socketEvent) + ) { + throw new EventRelayValidationError('Socket event must be a non-reserved event name'); + } + + const resourceType = requireTrimmed(input.resourceType, 'Resource type'); + if ( + resourceType.length > MAX_RESOURCE_TYPE_LENGTH || + !RESOURCE_TYPE_PATTERN.test(resourceType) + ) { + throw new EventRelayValidationError('Resource type is invalid'); + } + + const resourceIdPath = requireTrimmed(input.resourceIdPath, 'Resource ID path'); + if (resourceIdPath.length > MAX_RESOURCE_ID_PATH_LENGTH) { + throw new EventRelayValidationError('Resource ID path is too long'); + } + parseDotPath(resourceIdPath, 'Resource ID path'); + + const permission = requireTrimmed(input.permission, 'Permission'); + if (permission.length > MAX_PERMISSION_LENGTH || !PERMISSION_PATTERN.test(permission)) { + throw new EventRelayValidationError('Permission is invalid'); + } + + if (input.messageTemplate === undefined) { + throw new EventRelayValidationError('Message template is required'); + } + assertTemplateSize(input.messageTemplate); + + return { + name, + notes, + active: input.active !== false, + busEvent, + socketEvent, + resourceType, + resourceIdPath, + permission, + messageTemplate: input.messageTemplate, + }; +} + +export function validateResourceId(resourceId: unknown): string { + if (typeof resourceId !== 'string' && typeof resourceId !== 'number') { + throw new EventRelayValidationError('Resource ID must be a string'); + } + const value = String(resourceId).trim(); + if ( + !value || + value.length > MAX_RESOURCE_ID_LENGTH || + !RESOURCE_ID_PATTERN.test(value) + ) { + throw new EventRelayValidationError('Resource ID is invalid'); + } + return value; +} + +function requireTrimmed(value: unknown, label: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new EventRelayValidationError(`${label} is required`); + } + return value.trim(); +} diff --git a/modules/router/src/event-relays/validationError.ts b/modules/router/src/event-relays/validationError.ts new file mode 100644 index 000000000..90151b042 --- /dev/null +++ b/modules/router/src/event-relays/validationError.ts @@ -0,0 +1,6 @@ +export class EventRelayValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'EventRelayValidationError'; + } +} diff --git a/modules/router/src/metrics/index.ts b/modules/router/src/metrics/index.ts index 9dd6d9789..d3461c107 100644 --- a/modules/router/src/metrics/index.ts +++ b/modules/router/src/metrics/index.ts @@ -17,4 +17,25 @@ export default { labelNames: ['platform'], }, }, + eventRelaysEmitted: { + type: MetricType.Counter, + config: { + name: 'event_relays_emitted_total', + help: 'Tracks successfully emitted event-to-socket relays', + }, + }, + eventRelaysFailed: { + type: MetricType.Counter, + config: { + name: 'event_relays_failed_total', + help: 'Tracks event-to-socket relay parse, render, or emit failures', + }, + }, + eventRelaySubscriptionsDenied: { + type: MetricType.Counter, + config: { + name: 'event_relay_subscriptions_denied_total', + help: 'Tracks denied or unavailable event-relay socket subscriptions', + }, + }, }; diff --git a/modules/router/src/models/EventRelay.schema.ts b/modules/router/src/models/EventRelay.schema.ts new file mode 100644 index 000000000..e0c07b1d9 --- /dev/null +++ b/modules/router/src/models/EventRelay.schema.ts @@ -0,0 +1,94 @@ +import { + ConduitModel, + DatabaseProvider, + Indexable, + TYPE, +} from '@conduitplatform/grpc-sdk'; +import { ConduitActiveSchema } from '@conduitplatform/module-tools'; + +const schema: ConduitModel = { + _id: TYPE.ObjectId, + name: { + type: TYPE.String, + unique: true, + required: true, + }, + notes: { + type: TYPE.String, + required: false, + }, + active: { + type: TYPE.Boolean, + required: true, + default: true, + }, + busEvent: { + type: TYPE.String, + required: true, + }, + socketEvent: { + type: TYPE.String, + required: true, + }, + resourceType: { + type: TYPE.String, + required: true, + }, + resourceIdPath: { + type: TYPE.String, + required: true, + }, + permission: { + type: TYPE.String, + required: true, + }, + messageTemplate: { + type: TYPE.JSON, + required: true, + }, + createdAt: TYPE.Date, + updatedAt: TYPE.Date, +}; + +const modelOptions = { + timestamps: true, + conduit: { + permissions: { + extendable: true, + canCreate: false, + canModify: 'ExtensionOnly', + canDelete: false, + }, + }, +} as const; + +const collectionName = undefined; + +export class EventRelay extends ConduitActiveSchema { + private static _instance: EventRelay; + _id!: string; + declare name: string; + notes?: string; + active!: boolean; + busEvent!: string; + socketEvent!: string; + resourceType!: string; + resourceIdPath!: string; + permission!: string; + messageTemplate!: Indexable; + createdAt!: Date; + updatedAt!: Date; + + private constructor(database: DatabaseProvider) { + super(database, EventRelay.name, schema, modelOptions, collectionName); + } + + static getInstance(database?: DatabaseProvider) { + if (EventRelay._instance) return EventRelay._instance; + if (!database) { + throw new Error('No database instance provided!'); + } + EventRelay._instance = new EventRelay(database); + return EventRelay._instance; + } +} diff --git a/modules/router/src/models/index.ts b/modules/router/src/models/index.ts index 400d89cdd..ae11db58e 100644 --- a/modules/router/src/models/index.ts +++ b/modules/router/src/models/index.ts @@ -1,2 +1,3 @@ export * from './Client.schema.js'; export * from './AppMiddleware.schema.js'; +export * from './EventRelay.schema.js'; diff --git a/modules/router/tsconfig.test.json b/modules/router/tsconfig.test.json new file mode 100644 index 000000000..601e5ccee --- /dev/null +++ b/modules/router/tsconfig.test.json @@ -0,0 +1,24 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist-test", + "rootDir": "./src", + "declaration": false, + "sourceMap": false, + "types": ["node"] + }, + "include": [ + "src/event-relays/constants.ts", + "src/event-relays/validationError.ts", + "src/event-relays/path.ts", + "src/event-relays/template.ts", + "src/event-relays/validation.ts", + "src/event-relays/rooms.ts", + "src/event-relays/process.ts", + "src/event-relays/push.ts", + "src/event-relays/search.ts", + "src/event-relays/channels.ts", + "src/event-relays/authorize.ts", + "src/event-relays/*.test.ts" + ] +} From 6a3aa00e3372fd660f9d891d543d50eaa315fb7a Mon Sep 17 00:00:00 2001 From: John Chantzigoulas Date: Fri, 11 Sep 2026 14:11:10 +0300 Subject: [PATCH 2/4] refactor(router): drop tautological event-relay tests --- .../src/event-relays/EventRelayManager.ts | 4 ++-- .../event-relays/EventRelaySockets.test.ts | 19 +++++-------------- modules/router/src/event-relays/template.ts | 9 +++------ 3 files changed, 10 insertions(+), 22 deletions(-) diff --git a/modules/router/src/event-relays/EventRelayManager.ts b/modules/router/src/event-relays/EventRelayManager.ts index bddae2127..80cb809b7 100644 --- a/modules/router/src/event-relays/EventRelayManager.ts +++ b/modules/router/src/event-relays/EventRelayManager.ts @@ -94,7 +94,7 @@ export class EventRelayManager { private onBusMessage(channel: string, message: string): void { const relays = this.relaysByChannel.get(channel); - if (!relays || relays.length === 0) { + if (!relays?.length) { return; } @@ -128,7 +128,7 @@ export class EventRelayManager { ConduitGrpcSdk.Metrics?.increment('event_relays_failed_total'); ConduitGrpcSdk.Logger.error( `Event relay ${emission.relayId} emit failed on ${channel}: ${ - (err as Error).message + err instanceof Error ? err.message : String(err) }`, ); }, diff --git a/modules/router/src/event-relays/EventRelaySockets.test.ts b/modules/router/src/event-relays/EventRelaySockets.test.ts index 19f455b7a..c0a633bb1 100644 --- a/modules/router/src/event-relays/EventRelaySockets.test.ts +++ b/modules/router/src/event-relays/EventRelaySockets.test.ts @@ -1,7 +1,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { status } from '@grpc/grpc-js'; -import { authorizeRelaySubscription } from './authorize.js'; +import { authorizeRelaySubscription, RelaySubscriptionError } from './authorize.js'; import { eventRelayRoom } from './rooms.js'; import type { RelayLookup } from './authorize.js'; @@ -60,7 +60,8 @@ describe('authorizeRelaySubscription', () => { 'relay-1', 'order-1', ), - (err: any) => err.code === status.PERMISSION_DENIED, + (err: unknown) => + err instanceof RelaySubscriptionError && err.code === status.PERMISSION_DENIED, ); }); @@ -74,18 +75,8 @@ describe('authorizeRelaySubscription', () => { 'relay-1', 'order-1', ), - (err: any) => err.code === status.UNAVAILABLE, + (err: unknown) => + err instanceof RelaySubscriptionError && err.code === status.UNAVAILABLE, ); }); - - it('unsubscribe room matches subscribe room for the same ids', async () => { - const subscribed = await authorizeRelaySubscription( - mockSdk({ allow: true }), - mockManager(relay), - 'user-1', - 'relay-1', - 'order-1', - ); - assert.equal(subscribed, eventRelayRoom('relay-1', 'order-1')); - }); }); diff --git a/modules/router/src/event-relays/template.ts b/modules/router/src/event-relays/template.ts index f0f70dcc9..0fa4926ac 100644 --- a/modules/router/src/event-relays/template.ts +++ b/modules/router/src/event-relays/template.ts @@ -57,13 +57,10 @@ function renderValue(value: unknown, payload: unknown, depth: number): unknown { return value.map(item => renderValue(item, payload, depth + 1)); } if (value !== null && typeof value === 'object') { + const record = value as Record; const output: Record = {}; - for (const key of Object.keys(value as Record)) { - output[key] = renderValue( - (value as Record)[key], - payload, - depth + 1, - ); + for (const key of Object.keys(record)) { + output[key] = renderValue(record[key], payload, depth + 1); } return output; } From 9c7af8cfa1470c28998edabd27be3f976769cc4b Mon Sep 17 00:00:00 2001 From: Konstantinos Kopanidis Date: Sun, 13 Sep 2026 02:36:16 +0300 Subject: [PATCH 3/4] fix(router): Event Relays interventions (EventBus, Hermes, relay manager) (#1604) * fix(router): harden event relays for HA and auth lifetime EventBus uses a single Redis message dispatcher with subscriberId maps, SIGTERM/SIGINT shutdown, and subscribe-after-ACK. Hermes installs engine middleware once, skips empty-room namespace broadcasts, demotes hot-path logs, and re-authenticates recovered /events/ subscriptions. EventRelayManager gains coalesced periodic reconcile, compiled templates, inbound caps, TTL-cached emit-time ReBAC, room eviction, backpressure, preview API, and metrics/docs aligned with the interventions plan. * fix(router): address #1604 re-review P1 and P2 follow-ups Recovery re-auth keeps per-user subscription state across disconnect; emit-time ReBAC distinguishes unavailable vs deny; EventBus drops process signal handlers and adds subscribeAck; manager subscribes only after Redis ACK and broadcasts evictRelayIds on refresh; Hermes backpressure uses local sockets without emit acks; cache is bounded; preview caps sample JSON; handshake matcher rejects ticket/sid/non-namespace paths; socket middleware rebind avoids duplicate registration on first sockets enable. * fix(hermes): restore production tsc for engine middleware chain Type the Socket.IO engine middleware runner as Express NextFunction so recursive callbacks match registerGlobalMiddleware. Use Logger.info for socket trace lines (IConduitLogger has no debug). * fix(router): import Express Response for socket global middleware _rebindSocketGlobalMiddlewares passes handlers typed with fetch Response because Response was not imported from express, failing tsc against Hermes registerSocketGlobalMiddleware. * fix(router): quit EventBus on module shutdown signals Stop event relays and call grpcSdk.bus.quit() from Router.shutdown(), registered on SIGTERM/SIGINT with process exit so Redis teardown is not left to SDK signal handlers. Tighten registerGlobalMiddleware typing and trim handshake helper comment (deslop). * fix(router): pass-3 recovery, scoped emit, and subscription hygiene Recovery re-auth keeps membership on Authorization UNAVAILABLE and only leaves on deny; re-check subscriptions for restored er: rooms via room map. Prune user subscriptions on disconnect when no other socket holds them. Scope relay emits to receivers in the target room; use Engine.IO writeBuffer/writable for backpressure. Drop hot-path Hermes socket info logs. * fix(router): pass-4 recovery map, backpressure, emit tests Store relay subs on socket.data; resolve recovered rooms from context and TTL room map without last-writer userId. Prune room map when unused on disconnect. Backpressure uses writeBuffer depth only. Add Hermes/router tests for room-scoped emit and queue metric. * fix(router): clear eventRelaySubs from socket.data on recovery deny * fix(router): clear socket.data subs on recovery fail-closed leave --- libraries/grpc-sdk/package.json | 3 +- .../grpc-sdk/src/utilities/EventBus.test.ts | 121 +++++++++ libraries/grpc-sdk/src/utilities/EventBus.ts | 156 ++++++++--- libraries/grpc-sdk/tsconfig.test.json | 11 + libraries/hermes/package.json | 1 + libraries/hermes/src/Socket/Socket.ts | 256 ++++++++++++------ .../src/Socket/isSocketHandshake.test.ts | 26 ++ .../hermes/src/Socket/isSocketHandshake.ts | 26 ++ .../src/Socket/resolveEngineNamespacePath.ts | 11 + .../hermes/src/Socket/socketPushUtils.test.ts | 69 +++++ .../hermes/src/Socket/socketPushUtils.ts | 36 +++ libraries/hermes/src/index.ts | 23 +- libraries/hermes/src/interfaces/Socket.ts | 11 + libraries/hermes/src/interfaces/SocketPush.ts | 4 + libraries/hermes/tsconfig.json | 3 +- libraries/hermes/tsconfig.test.json | 17 ++ modules/router/README.mdx | 25 +- modules/router/src/Router.ts | 50 +++- modules/router/src/admin/event-relays.ts | 34 ++- modules/router/src/admin/index.ts | 15 + .../src/event-relays/EventRelayManager.ts | 242 ++++++++++++++--- .../src/event-relays/EventRelaySockets.ts | 68 ++++- modules/router/src/event-relays/authorize.ts | 2 - modules/router/src/event-relays/compile.ts | 50 ++++ modules/router/src/event-relays/constants.ts | 6 + .../router/src/event-relays/follow-up.test.ts | 186 +++++++++++++ .../src/event-relays/interventions.test.ts | 23 ++ .../router/src/event-relays/process.test.ts | 3 + modules/router/src/event-relays/process.ts | 24 +- modules/router/src/event-relays/push.ts | 13 +- .../src/event-relays/rebacCache.test.ts | 43 +++ modules/router/src/event-relays/rebacCache.ts | 108 ++++++++ modules/router/src/event-relays/recovery.ts | 85 ++++++ .../src/event-relays/relaySocketData.ts | 65 +++++ modules/router/src/event-relays/rooms.ts | 4 + .../router/src/event-relays/subscriptions.ts | 224 +++++++++++++++ modules/router/src/index.ts | 18 ++ modules/router/src/metrics/index.ts | 35 +++ modules/router/tsconfig.test.json | 3 + 39 files changed, 1912 insertions(+), 188 deletions(-) create mode 100644 libraries/grpc-sdk/src/utilities/EventBus.test.ts create mode 100644 libraries/grpc-sdk/tsconfig.test.json create mode 100644 libraries/hermes/src/Socket/isSocketHandshake.test.ts create mode 100644 libraries/hermes/src/Socket/isSocketHandshake.ts create mode 100644 libraries/hermes/src/Socket/resolveEngineNamespacePath.ts create mode 100644 libraries/hermes/src/Socket/socketPushUtils.test.ts create mode 100644 libraries/hermes/src/Socket/socketPushUtils.ts create mode 100644 libraries/hermes/tsconfig.test.json create mode 100644 modules/router/src/event-relays/compile.ts create mode 100644 modules/router/src/event-relays/follow-up.test.ts create mode 100644 modules/router/src/event-relays/interventions.test.ts create mode 100644 modules/router/src/event-relays/rebacCache.test.ts create mode 100644 modules/router/src/event-relays/rebacCache.ts create mode 100644 modules/router/src/event-relays/recovery.ts create mode 100644 modules/router/src/event-relays/relaySocketData.ts create mode 100644 modules/router/src/event-relays/subscriptions.ts diff --git a/libraries/grpc-sdk/package.json b/libraries/grpc-sdk/package.json index 25809a993..bbf717933 100644 --- a/libraries/grpc-sdk/package.json +++ b/libraries/grpc-sdk/package.json @@ -25,7 +25,8 @@ "prepublish": "npm run build", "prebuild": "npm run protoc", "build": "rimraf dist && tsup", - "protoc": "sh build.sh" + "protoc": "sh build.sh", + "test": "npx tsc -p tsconfig.test.json && node --test dist-test/utilities/EventBus.test.js" }, "license": "MIT", "dependencies": { diff --git a/libraries/grpc-sdk/src/utilities/EventBus.test.ts b/libraries/grpc-sdk/src/utilities/EventBus.test.ts new file mode 100644 index 000000000..c1999901d --- /dev/null +++ b/libraries/grpc-sdk/src/utilities/EventBus.test.ts @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { EventBus } from './EventBus.js'; + +type Listener = (channel: string, message: string) => void; + +class FakeRedis { + handlers: Record = {}; + subscribed = new Set(); + failNext = new Set(); + + on(event: string, listener: Listener) { + this.handlers[event] = this.handlers[event] ?? []; + this.handlers[event].push(listener); + } + + subscribe(channel: string, cb?: (err?: Error | null) => void) { + if (this.failNext.has(channel)) { + this.failNext.delete(channel); + cb?.(new Error('subscribe failed')); + return; + } + this.subscribed.add(channel); + cb?.(null); + } + + unsubscribe(_channel: string, cb?: () => void) { + cb?.(); + } + + publish(_channel: string, _message: string) {} + + quit() {} + + emitMessage(channel: string, message: string) { + for (const listener of this.handlers.message ?? []) { + listener(channel, message); + } + } +} + +function createBus() { + const sub = new FakeRedis(); + const pub = new FakeRedis(); + const manager = { + getClient: () => sub, + }; + const bus = new EventBus(manager as never); + (bus as unknown as { _clientSubscriber: FakeRedis })._clientSubscriber = sub; + (bus as unknown as { _clientPublisher: FakeRedis })._clientPublisher = pub; + return { bus, sub }; +} + +describe('EventBus', () => { + it('fires once after deactivate/reactivate on the same channel', () => { + const { bus, sub } = createBus(); + let count = 0; + bus.subscribe( + 'database:update:Order', + () => { + count += 1; + }, + 'relay-a', + ); + bus.unsubscribe('relay-a'); + bus.subscribe( + 'database:update:Order', + () => { + count += 1; + }, + 'relay-a', + ); + sub.emitMessage('database:update:Order', '{"ok":true}'); + assert.equal(count, 1); + }); + + it('keeps the second subscriber when the first is removed', () => { + const { bus, sub } = createBus(); + let first = 0; + let second = 0; + bus.subscribe( + 'chan', + () => { + first += 1; + }, + 'one', + ); + bus.subscribe( + 'chan', + () => { + second += 1; + }, + 'two', + ); + bus.unsubscribe('one'); + sub.emitMessage('chan', 'x'); + assert.equal(first, 0); + assert.equal(second, 1); + }); + + it('subscribeAck rejects when Redis subscribe fails', async () => { + const { bus, sub } = createBus(); + sub.failNext.add('chan'); + await assert.rejects( + () => + bus.subscribeAck( + 'chan', + () => {}, + 'relay-a', + ), + /subscribe failed/, + ); + sub.failNext.delete('chan'); + let count = 0; + await bus.subscribeAck('chan', () => { + count += 1; + }, 'relay-a'); + sub.emitMessage('chan', 'x'); + assert.equal(count, 1); + }); +}); diff --git a/libraries/grpc-sdk/src/utilities/EventBus.ts b/libraries/grpc-sdk/src/utilities/EventBus.ts index 8cd8512f1..9388ad483 100644 --- a/libraries/grpc-sdk/src/utilities/EventBus.ts +++ b/libraries/grpc-sdk/src/utilities/EventBus.ts @@ -3,35 +3,58 @@ import { Cluster, Redis } from 'ioredis'; import crypto from 'crypto'; import { getLogger } from './GrpcSdkContext.js'; +type ChannelCallbacks = Map void>; + export class EventBus { private _clientSubscriber: Redis | Cluster; private _clientPublisher: Redis | Cluster; - private _subscribedChannels: { [listener: string]: ((message: string) => void)[] }; - private _subscribers: { [listener: string]: [string, number] }; + /** channelName -> subscriberId -> callback */ + private _channelCallbacks: Map; + /** subscriberId -> channelName */ + private _subscriberChannels: Map; + /** channels with a successful Redis SUBSCRIBE */ + private _redisSubscribedChannels: Set; + private _subscribeInFlight = new Map>(); private _signature: string; + private _anonymousSubscriberSeq = 0; + private _shuttingDown = false; constructor(redisManager: RedisManager) { - this._subscribedChannels = {}; - this._subscribers = {}; + this._channelCallbacks = new Map(); + this._subscriberChannels = new Map(); + this._redisSubscribedChannels = new Set(); this._clientSubscriber = redisManager.getClient({ keyPrefix: 'bus_' }); this._clientPublisher = redisManager.getClient({ keyPrefix: 'bus_' }); this._signature = crypto.randomBytes(20).toString('hex'); this._clientSubscriber.on('ready', () => { getLogger().log('The Bus is in the station...hehe'); }); + this._clientSubscriber.on('message', (channel: string, message: string) => { + this.dispatch(channel, message); + }); process.on('exit', () => { - this._clientSubscriber.quit(); - this._clientPublisher.quit(); + this.quit(); }); } + quit(): void { + if (this._shuttingDown) return; + this._shuttingDown = true; + void this._clientSubscriber.quit(); + void this._clientPublisher.quit(); + } + unsubscribe(subscriberId: string): void { - if (this._subscribers[subscriberId]) { - const [channelName, index] = this._subscribers[subscriberId]; - this._subscribedChannels[channelName].splice(index, 1); - delete this._subscribers[subscriberId]; - if (this._subscribedChannels[channelName].length === 0) { - delete this._subscribedChannels[channelName]; + const channelName = this._subscriberChannels.get(subscriberId); + if (!channelName) { + return; + } + const callbacks = this._channelCallbacks.get(channelName); + callbacks?.delete(subscriberId); + this._subscriberChannels.delete(subscriberId); + if (callbacks && callbacks.size === 0) { + this._channelCallbacks.delete(channelName); + if (this._redisSubscribedChannels.delete(channelName)) { this._clientSubscriber.unsubscribe(channelName, () => {}); } } @@ -42,43 +65,100 @@ export class EventBus { callback: (message: string) => void, subscriberId?: string, ): void { + void this.subscribeAck(channelName, callback, subscriberId).catch(err => { + getLogger().error( + `EventBus subscribe failed for ${channelName}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }); + } + + async subscribeAck( + channelName: string, + callback: (message: string) => void, + subscriberId?: string, + ): Promise { + if (this._shuttingDown) { + return; + } + const id = + subscriberId ?? + `anon:${channelName}:${++this._anonymousSubscriberSeq}:${crypto.randomBytes(4).toString('hex')}`; if (subscriberId) { - // if subscriberId is provided, and it is already subscribed, unsubscribe it first this.unsubscribe(subscriberId); } - if (this._subscribedChannels[channelName]) { - this._subscribedChannels[channelName].push(callback); - if (subscriberId) { - this._subscribers[subscriberId] = [ - channelName, - this._subscribedChannels[channelName].length - 1, - ]; - } + + let callbacks = this._channelCallbacks.get(channelName); + if (!callbacks) { + callbacks = new Map(); + this._channelCallbacks.set(channelName, callbacks); + } + callbacks.set(id, callback); + this._subscriberChannels.set(id, channelName); + + if (this._redisSubscribedChannels.has(channelName)) { return; } - this._subscribedChannels[channelName] = [callback]; - this._clientSubscriber.subscribe(channelName, () => {}); - const self = this; - this._clientSubscriber.on('message', (channel: string, message: string) => { - if (channel !== channelName) return; - // if the message supports the signature - if (message.indexOf('CND_Signature') !== -1) { - // if the message does not contain this module's signature - if (message.indexOf(self._signature) === -1) { - self._subscribedChannels[channelName].forEach(fn => { - fn(message.split('CND_Signature:')[0]); - }); - } - } else { - self._subscribedChannels[channelName].forEach(fn => { - fn(message); + + let inFlight = this._subscribeInFlight.get(channelName); + if (!inFlight) { + inFlight = new Promise((resolve, reject) => { + this._clientSubscriber.subscribe(channelName, err => { + if (err) { + reject(err); + return; + } + this._redisSubscribedChannels.add(channelName); + resolve(); }); + }).finally(() => { + this._subscribeInFlight.delete(channelName); + }); + this._subscribeInFlight.set(channelName, inFlight); + } + + try { + await inFlight; + } catch (err) { + if (!this._redisSubscribedChannels.has(channelName)) { + this.removeChannelCallbacks(channelName); } - }); + this.unsubscribe(id); + throw err; + } } publish(channelName: string, message: string) { message = message + `CND_Signature:${this._signature}`; this._clientPublisher.publish(channelName, message); } + + private removeChannelCallbacks(channelName: string): void { + const callbacks = this._channelCallbacks.get(channelName); + if (!callbacks) { + return; + } + for (const subId of callbacks.keys()) { + this._subscriberChannels.delete(subId); + } + this._channelCallbacks.delete(channelName); + } + + private dispatch(channel: string, message: string): void { + const callbacks = this._channelCallbacks.get(channel); + if (!callbacks || callbacks.size === 0) { + return; + } + let payload = message; + if (message.indexOf('CND_Signature') !== -1) { + if (message.indexOf(this._signature) !== -1) { + return; + } + payload = message.split('CND_Signature:')[0]; + } + for (const fn of callbacks.values()) { + fn(payload); + } + } } diff --git a/libraries/grpc-sdk/tsconfig.test.json b/libraries/grpc-sdk/tsconfig.test.json new file mode 100644 index 000000000..121161cd7 --- /dev/null +++ b/libraries/grpc-sdk/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist-test", + "rootDir": "./src", + "declaration": false, + "sourceMap": false, + "types": ["node"] + }, + "include": ["src/utilities/EventBus.ts", "src/utilities/EventBus.test.ts"] +} diff --git a/libraries/hermes/package.json b/libraries/hermes/package.json index 62f32ab5a..3beffb157 100644 --- a/libraries/hermes/package.json +++ b/libraries/hermes/package.json @@ -11,6 +11,7 @@ "scripts": { "prepublish": "npm run build", "build": "rimraf dist && tsc", + "test": "npx tsc -p tsconfig.test.json && node --test dist-test/Socket/*.test.js", "publish": "npm publish", "postbuild": "copyfiles -u 1 src/*.proto src/**/*.json ./dist/" }, diff --git a/libraries/hermes/src/Socket/Socket.ts b/libraries/hermes/src/Socket/Socket.ts index 74ced4e2b..7084fa857 100644 --- a/libraries/hermes/src/Socket/Socket.ts +++ b/libraries/hermes/src/Socket/Socket.ts @@ -16,6 +16,11 @@ import { import ObjectHash from 'object-hash'; import { ConduitError, ConduitGrpcSdk } from '@conduitplatform/grpc-sdk'; import { buildSocketMiddlewareParams } from './buildSocketMiddlewareParams.js'; +import { resolveEngineNamespacePath } from './resolveEngineNamespacePath.js'; +import { + filterRemoteSocketsByUserAndRooms, + isEngineSocketBackpressured, +} from './socketPushUtils.js'; export class SocketController extends ConduitRouter { private readonly httpServer: httpServer; @@ -78,6 +83,22 @@ export class SocketController extends ConduitRouter { `Socket connection error, context: ${err?.context ?? 'N/A'}`, ); }); + + this.io.engine.use((req: any, res: any, next: NextFunction) => { + req.path = resolveEngineNamespacePath(req); + let index = 0; + const run: NextFunction = err => { + if (err) { + return next(err); + } + const middleware = this.globalMiddlewares[index++]; + if (!middleware) { + return next(); + } + middleware(req, res, run); + }; + run(); + }); } registerGlobalMiddleware( @@ -107,12 +128,6 @@ export class SocketController extends ConduitRouter { this._registeredNamespaces.set(namespace, conduitSocket); const self = this; - this.globalMiddlewares.forEach(middleware => { - self.io.engine.use((req: any, res: any, next: any) => { - req.path = namespace; - middleware(req, res, next); - }); - }); this.io.of(namespace).use((socket, next) => { const context = buildSocketMiddlewareParams(socket); self @@ -129,13 +144,21 @@ export class SocketController extends ConduitRouter { this.io.of(namespace).on('connect', socket => { if (socket.recovered) { - ConduitGrpcSdk.Logger.info( - `Socket recovered: ${socket.id} to namespace: ${namespace}`, - ); + const recovered = conduitSocket.executeRecovered({ + event: 'recovered', + socketId: socket.id, + context: socket.data, + recoveredRooms: [...socket.rooms].filter(room => room.startsWith('er:')), + }); + if (recovered) { + recovered + .then(res => this.handleResponse(res, socket, namespace)) + .catch(e => { + ConduitGrpcSdk.Logger.error(e); + socket.emit('conduit_error', e); + }); + } } else { - ConduitGrpcSdk.Logger.info( - `Socket connected: ${socket.id} to namespace: ${namespace}`, - ); conduitSocket .executeRequest({ event: 'connect', @@ -150,7 +173,6 @@ export class SocketController extends ConduitRouter { } socket.onAny((event, ...args) => { - ConduitGrpcSdk.Logger.info(`Socket event: ${event} from socket: ${socket.id}`); conduitSocket .executeRequest({ event, @@ -166,9 +188,6 @@ export class SocketController extends ConduitRouter { }); socket.on('disconnect', () => { - ConduitGrpcSdk.Logger.info( - `Socket disconnected: ${socket.id} from namespace: ${namespace}`, - ); conduitSocket .executeRequest({ event: 'disconnect', @@ -184,82 +203,162 @@ export class SocketController extends ConduitRouter { }); } - async handleSocketPush(push: SocketPush) { + async handleSocketPush(push: SocketPush): Promise { const localOnly = push.localOnly === true; if (push.event === 'join-room') { - if (push.rooms.length === 0) return; + if (push.rooms.length === 0) return false; const filteredSockets = await this.findAndFilterSockets( push.receivers, push.namespace, localOnly, ); for (const socket of filteredSockets) { - ConduitGrpcSdk.Logger.info( - `Socket ${socket.id} joining rooms: ${push.rooms.join(', ')} in namespace: ${ - push.namespace - }`, - ); socket.join(push.rooms); } + return true; } else if (push.event === 'leave-room') { if (push.rooms && push.rooms.length !== 0) { - const filteredSockets = await this.findAndFilterSockets( - push.receivers, - push.namespace, - localOnly, - ); + const filteredSockets = await this.socketsForRoomPush(push, localOnly); for (const socket of filteredSockets) { for (const room of push.rooms) { - ConduitGrpcSdk.Logger.info( - `Socket ${socket.id} leaving room: ${room} in namespace: ${push.namespace}`, - ); socket.leave(room); } } } + return true; } else if (isInstanceOfEventResponse(push)) { if ( (isNil(push.receivers) || push.receivers.length === 0) && push.rooms.length === 0 ) { - ConduitGrpcSdk.Logger.info( - `Emitting event: ${push.event} to all sockets in namespace: ${push.namespace}`, - ); + return false; + } + if (push.receivers.length !== 0) { const nsp = this.io.of(push.namespace); - if (localOnly) { - nsp.local.emit(push.event, push.data); - } else { - nsp.emit(push.event, push.data); + const filteredSockets = await this.findAndFilterSockets( + push.receivers, + push.namespace, + localOnly, + push.rooms, + ); + if (push.skipEmptyRooms && filteredSockets.length === 0) { + return false; } - } else { - if (push.rooms.length !== 0) { - ConduitGrpcSdk.Logger.info( - `Emitting event: ${push.event} to rooms: ${push.rooms.join( - ', ', - )} in namespace: ${push.namespace}`, - ); - const target = this.io.of(push.namespace).to(push.rooms); - if (localOnly) { - target.local.emit(push.event, push.data); - } else { - target.emit(push.event, push.data); + for (const remote of filteredSockets) { + const local = localOnly ? nsp.sockets.get(remote.id) : undefined; + if ( + push.boundedEmit && + localOnly && + local && + this.isLocalSocketBackpressured(local) + ) { + ConduitGrpcSdk.Metrics?.increment('event_relays_emit_dropped_total'); + local.disconnect(true); + continue; } + remote.emit(push.event, push.data); } - if (push.receivers.length !== 0) { - const filteredSockets = await this.findAndFilterSockets( - push.receivers, - push.namespace, - localOnly, - ); - for (const socket of filteredSockets) { - ConduitGrpcSdk.Logger.info( - `Emitting event: ${push.event} to socket: ${socket.id} in namespace: ${push.namespace}`, - ); - socket.emit(push.event, push.data); + return true; + } + if (push.rooms.length !== 0) { + const emitted = await this.emitEventToRooms(push, localOnly); + if (!emitted) { + return false; + } + } + return true; + } + return false; + } + + async getLocalRoomUserIds(namespace: string, room: string): Promise { + const sockets = await this.io.of(namespace).in(room).local.fetchSockets(); + const userIds = new Set(); + for (const socket of sockets) { + const userId = socket.data?.user?._id; + if (typeof userId === 'string' && userId.length > 0) { + userIds.add(userId); + } + } + return [...userIds]; + } + + async getLocalRoomsWithPrefix(namespace: string, prefix: string): Promise { + const adapter = this.io.of(namespace).adapter as { rooms?: Map }; + const rooms = adapter.rooms; + if (!rooms) { + return []; + } + return [...rooms.keys()].filter(room => room.startsWith(prefix)); + } + + private async emitEventToRooms(push: SocketPush, localOnly: boolean): Promise { + const nsp = this.io.of(push.namespace); + const localSockets = localOnly ? this.localSocketsInRooms(nsp, push.rooms) : []; + if (push.skipEmptyRooms || push.boundedEmit) { + if (localOnly && localSockets.length === 0) { + return false; + } + if (push.boundedEmit && localOnly) { + for (const socket of localSockets) { + if (this.isLocalSocketBackpressured(socket)) { + ConduitGrpcSdk.Metrics?.increment('event_relays_emit_dropped_total'); + socket.disconnect(true); } } } } + + const target = nsp.to(push.rooms); + if (localOnly) { + target.local.emit(push.event, push.data); + } else { + target.emit(push.event, push.data); + } + return true; + } + + private localSocketsInRooms( + nsp: ReturnType, + rooms: string[], + ): Socket[] { + const sockets: Socket[] = []; + for (const socket of nsp.sockets.values()) { + if (rooms.some(room => socket.rooms.has(room))) { + sockets.push(socket); + } + } + return sockets; + } + + private isLocalSocketBackpressured(socket: Socket): boolean { + return isEngineSocketBackpressured( + socket.conn as unknown as { writeBuffer?: unknown[] }, + ); + } + + private async socketsForRoomPush( + push: SocketPush, + localOnly: boolean, + ): Promise[]> { + if (push.receivers.length > 0) { + return this.findAndFilterSockets(push.receivers, push.namespace, localOnly); + } + const nsp = this.io.of(push.namespace); + const seen = new Set(); + const sockets: RemoteSocket[] = []; + for (const room of push.rooms) { + const inRoom = localOnly + ? await nsp.in(room).local.fetchSockets() + : await nsp.in(room).fetchSockets(); + for (const socket of inRoom) { + if (!seen.has(socket.id)) { + seen.add(socket.id); + sockets.push(socket); + } + } + } + return sockets; } private async handleResponse( @@ -269,19 +368,11 @@ export class SocketController extends ConduitRouter { ) { if (res.event === 'join-room') { if (res.rooms && res.rooms.length !== 0) { - ConduitGrpcSdk.Logger.info( - `Socket ${socket.id} joining rooms: ${res.rooms.join( - ', ', - )} in namespace: ${namespace}`, - ); socket.join(res.rooms); } } else if (res.event === 'leave-room') { if (res.rooms && res.rooms.length !== 0) { for (const room of res.rooms) { - ConduitGrpcSdk.Logger.info( - `Socket ${socket.id} leaving room: ${room} in namespace: ${namespace}`, - ); socket.leave(room); } } @@ -290,17 +381,9 @@ export class SocketController extends ConduitRouter { (!res.receivers || res.receivers.length === 0) && (!res.rooms || res.rooms.length === 0) ) { - ConduitGrpcSdk.Logger.info( - `Emitting event: ${res.event} to all sockets in namespace: ${namespace}`, - ); socket.emit(res.event, JSON.parse(res.data)); } else { if (res.rooms && res.rooms.length !== 0) { - ConduitGrpcSdk.Logger.info( - `Emitting event: ${res.event} to rooms: ${res.rooms.join( - ', ', - )} in namespace: ${namespace}`, - ); this.io.of(namespace).to(res.rooms).emit(res.event, JSON.parse(res.data)); } if (res.receivers && res.receivers.length !== 0) { @@ -309,9 +392,6 @@ export class SocketController extends ConduitRouter { namespace, ); for (const socket of filteredSockets) { - ConduitGrpcSdk.Logger.info( - `Emitting event: ${res.event} to socket: ${socket.id} in namespace: ${namespace}`, - ); socket.emit(res.event, JSON.parse(res.data)); } } @@ -323,15 +403,19 @@ export class SocketController extends ConduitRouter { userIds: string[], namespace: string, localOnly: boolean = false, + rooms: string[] = [], ): Promise[]> { const nsp = this.io.of(namespace); const sockets = localOnly ? await nsp.local.fetchSockets() : await nsp.fetchSockets(); - const userIdSet = new Set(userIds); - return sockets.filter(socket => { - if (socket.data && socket.data.user) { - return userIdSet.has(socket.data.user._id); - } - }); + return filterRemoteSocketsByUserAndRooms( + sockets.map(socket => ({ + id: socket.id, + data: socket.data, + rooms: socket.rooms, + })), + userIds, + rooms, + ).map(filtered => sockets.find(s => s.id === filtered.id)!); } protected _refreshRouter(): void { diff --git a/libraries/hermes/src/Socket/isSocketHandshake.test.ts b/libraries/hermes/src/Socket/isSocketHandshake.test.ts new file mode 100644 index 000000000..267de330f --- /dev/null +++ b/libraries/hermes/src/Socket/isSocketHandshake.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { isSocketHandshake } from './isSocketHandshake.js'; + +describe('isSocketHandshake', () => { + it('matches Engine.IO handshake query params only', () => { + assert.equal( + isSocketHandshake({ url: '/events/?EIO=4&transport=polling&t=abc' }), + true, + ); + assert.equal(isSocketHandshake({ url: '/realtime/?EIO=4&transport=websocket' }), true); + }); + + it('rejects HTTP routes that merely mention realtime or established sessions', () => { + assert.equal( + isSocketHandshake({ url: '/realtime/ticket?EIO=4&transport=polling' }), + false, + ); + assert.equal(isSocketHandshake({ originalUrl: '/realtime' }), false); + assert.equal(isSocketHandshake({ url: '/graphql?EIO=4&transport=polling' }), false); + assert.equal( + isSocketHandshake({ url: '/events/?EIO=4&transport=polling&sid=abc' }), + false, + ); + }); +}); diff --git a/libraries/hermes/src/Socket/isSocketHandshake.ts b/libraries/hermes/src/Socket/isSocketHandshake.ts new file mode 100644 index 000000000..aa216d2ac --- /dev/null +++ b/libraries/hermes/src/Socket/isSocketHandshake.ts @@ -0,0 +1,26 @@ +export function isSocketHandshake(req: { url?: string; originalUrl?: string }): boolean { + const raw = req.url ?? req.originalUrl ?? ''; + if (!raw) { + return false; + } + const queryIndex = raw.indexOf('?'); + const pathname = queryIndex === -1 ? raw : raw.slice(0, queryIndex); + if (pathname.includes('ticket')) { + return false; + } + const query = queryIndex === -1 ? '' : raw.slice(queryIndex + 1); + if (!query) { + return false; + } + const params = new URLSearchParams(query); + if (!params.has('EIO') || !params.has('transport')) { + return false; + } + if (params.has('sid')) { + return false; + } + if (pathname !== '/' && !pathname.endsWith('/')) { + return false; + } + return true; +} diff --git a/libraries/hermes/src/Socket/resolveEngineNamespacePath.ts b/libraries/hermes/src/Socket/resolveEngineNamespacePath.ts new file mode 100644 index 000000000..e36aad12b --- /dev/null +++ b/libraries/hermes/src/Socket/resolveEngineNamespacePath.ts @@ -0,0 +1,11 @@ +/** + * Derive the Socket.IO namespace path from an Engine.IO handshake request URL. + */ +export function resolveEngineNamespacePath(req: { url?: string }): string { + const url = req.url ?? '/'; + const pathname = url.split('?')[0] ?? '/'; + if (pathname === '/' || pathname === '') { + return '/'; + } + return pathname.endsWith('/') ? pathname : `${pathname}/`; +} diff --git a/libraries/hermes/src/Socket/socketPushUtils.test.ts b/libraries/hermes/src/Socket/socketPushUtils.test.ts new file mode 100644 index 000000000..7f76e2904 --- /dev/null +++ b/libraries/hermes/src/Socket/socketPushUtils.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { + filterRemoteSocketsByUserAndRooms, + isEngineSocketBackpressured, + WRITE_BUFFER_PACKET_HIGH_WATER, +} from './socketPushUtils.js'; + +describe('filterRemoteSocketsByUserAndRooms', () => { + const roomA = 'er:relay-1:aaa'; + const roomB = 'er:relay-1:bbb'; + + it('emits only to sockets in the target room for the same user', () => { + const sockets = [ + { + id: 'tab-a', + data: { user: { _id: 'user-1' } }, + rooms: new Set([roomA]), + }, + { + id: 'tab-b', + data: { user: { _id: 'user-1' } }, + rooms: new Set([roomB]), + }, + ]; + + const filtered = filterRemoteSocketsByUserAndRooms(sockets, ['user-1'], [roomA]); + assert.deepEqual( + filtered.map(s => s.id), + ['tab-a'], + ); + }); +}); + +describe('isEngineSocketBackpressured', () => { + it('uses writeBuffer queue depth only', () => { + assert.equal(isEngineSocketBackpressured(undefined), false); + assert.equal( + isEngineSocketBackpressured({ writeBuffer: new Array(WRITE_BUFFER_PACKET_HIGH_WATER) }), + false, + ); + assert.equal( + isEngineSocketBackpressured({ + writeBuffer: new Array(WRITE_BUFFER_PACKET_HIGH_WATER + 1), + }), + true, + ); + assert.equal( + isEngineSocketBackpressured({ + writeBuffer: [], + }), + false, + ); + }); +}); + +describe('SocketController relay emit', () => { + it('does not reference writableLength or client emit ack callbacks', () => { + const socketSource = readFileSync( + path.join(path.dirname(fileURLToPath(import.meta.url)), '../../src/Socket/Socket.ts'), + 'utf8', + ); + assert.doesNotMatch(socketSource, /writableLength/); + assert.doesNotMatch(socketSource, /\.emit\([^)]*,\s*[^,)]+,\s*\(\)\s*=>/); + }); +}); diff --git a/libraries/hermes/src/Socket/socketPushUtils.ts b/libraries/hermes/src/Socket/socketPushUtils.ts new file mode 100644 index 000000000..6b3e14feb --- /dev/null +++ b/libraries/hermes/src/Socket/socketPushUtils.ts @@ -0,0 +1,36 @@ +export const WRITE_BUFFER_PACKET_HIGH_WATER = 64; + +type SocketLike = { + id: string; + data?: { user?: { _id?: string } }; + rooms: Set; +}; + +export function filterRemoteSocketsByUserAndRooms( + sockets: SocketLike[], + userIds: string[], + rooms: string[], +): SocketLike[] { + const userIdSet = new Set(userIds); + const roomSet = rooms.length > 0 ? new Set(rooms) : null; + return sockets.filter(socket => { + if (!socket.data?.user?._id) { + return false; + } + if (!userIdSet.has(socket.data.user._id)) { + return false; + } + if (roomSet) { + return [...roomSet].some(room => socket.rooms.has(room)); + } + return true; + }); +} + +export function isEngineSocketBackpressured( + conn: { writeBuffer?: unknown[] } | undefined, + highWater = WRITE_BUFFER_PACKET_HIGH_WATER, +): boolean { + const pending = conn?.writeBuffer?.length ?? 0; + return pending > highWater; +} diff --git a/libraries/hermes/src/index.ts b/libraries/hermes/src/index.ts index ab17086b2..ac1a9960b 100644 --- a/libraries/hermes/src/index.ts +++ b/libraries/hermes/src/index.ts @@ -235,6 +235,12 @@ export class ConduitRoutingController { } } + registerSocketGlobalMiddleware( + middleware: (req: ConduitRequest, res: Response, next: NextFunction) => void, + ) { + this._socketRouter?.registerGlobalMiddleware(middleware); + } + registerRouteMiddleware(middleware: ConduitMiddleware, moduleUrl: string) { this._restRouter?.registerMiddleware(middleware, moduleUrl); this._graphQLRouter?.registerMiddleware(middleware, moduleUrl); @@ -322,7 +328,20 @@ export class ConduitRoutingController { } async socketPush(data: SocketPush) { - await this._socketRouter?.handleSocketPush(data); + return (await this._socketRouter?.handleSocketPush(data)) ?? false; + } + + getLocalRoomUserIds(namespace: string, room: string): Promise { + return ( + this._socketRouter?.getLocalRoomUserIds(namespace, room) ?? Promise.resolve([]) + ); + } + + getLocalRoomsWithPrefix(namespace: string, prefix: string): Promise { + return ( + this._socketRouter?.getLocalRoomsWithPrefix(namespace, prefix) ?? + Promise.resolve([]) + ); } /** True if any enabled transport would change this route (new or definition changed). */ @@ -451,6 +470,8 @@ export class ConduitRoutingController { } } +export { isSocketHandshake } from './Socket/isSocketHandshake.js'; +export { resolveEngineNamespacePath } from './Socket/resolveEngineNamespacePath.js'; export * from './interfaces/index.js'; export * from './types/index.js'; export * from './classes/index.js'; diff --git a/libraries/hermes/src/interfaces/Socket.ts b/libraries/hermes/src/interfaces/Socket.ts index 204707624..7c7851094 100644 --- a/libraries/hermes/src/interfaces/Socket.ts +++ b/libraries/hermes/src/interfaces/Socket.ts @@ -10,6 +10,7 @@ export interface ConduitSocketParameters { socketId: string; params?: UntypedArray; context?: Indexable; + recoveredRooms?: string[]; } export type ConduitSocketParamTypes = (TYPE | ConduitSocketParamTypes)[]; @@ -19,6 +20,7 @@ export interface ConduitSocketOptions { name?: string; description?: string; middlewares?: string[]; + onRecovered?: ConduitSocketEventHandler; } export type EventResponse = { @@ -85,6 +87,15 @@ export class ConduitSocket { } return Promise.reject('no such event registered'); } + + executeRecovered( + request: ConduitSocketParameters, + ): ConduitSocketHandlerResponse | null { + if (!this._input.onRecovered) { + return null; + } + return this._input.onRecovered(request); + } } export interface EventsProtoDescription { diff --git a/libraries/hermes/src/interfaces/SocketPush.ts b/libraries/hermes/src/interfaces/SocketPush.ts index 8bbabbc85..7d0f572d3 100644 --- a/libraries/hermes/src/interfaces/SocketPush.ts +++ b/libraries/hermes/src/interfaces/SocketPush.ts @@ -5,4 +5,8 @@ export interface SocketPush { rooms: string[]; namespace: string; localOnly?: boolean; + /** Skip emit when the local room has no connected sockets. */ + skipEmptyRooms?: boolean; + /** Drop or disconnect slow clients instead of blocking the caller. */ + boundedEmit?: boolean; } diff --git a/libraries/hermes/tsconfig.json b/libraries/hermes/tsconfig.json index cd32b1ca8..225b927d7 100644 --- a/libraries/hermes/tsconfig.json +++ b/libraries/hermes/tsconfig.json @@ -65,5 +65,6 @@ /* Advanced Options */ "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ - } + }, + "exclude": ["src/**/*.test.ts"] } diff --git a/libraries/hermes/tsconfig.test.json b/libraries/hermes/tsconfig.test.json new file mode 100644 index 000000000..8aecbdee9 --- /dev/null +++ b/libraries/hermes/tsconfig.test.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist-test", + "rootDir": "./src", + "declaration": false, + "sourceMap": false, + "types": ["node"] + }, + "include": [ + "src/Socket/isSocketHandshake.ts", + "src/Socket/isSocketHandshake.test.ts", + "src/Socket/socketPushUtils.ts", + "src/Socket/socketPushUtils.test.ts" + ], + "exclude": [] +} diff --git a/modules/router/README.mdx b/modules/router/README.mdx index c896f2cee..a0dad3165 100644 --- a/modules/router/README.mdx +++ b/modules/router/README.mdx @@ -92,12 +92,12 @@ import { io } from 'socket.io-client'; const socket = io(`${SOCKET_BASE_URL}/events/`, { path: '/realtime', - extraHeaders: { - authorization: `Bearer ${accessToken}`, - }, + auth: { token: accessToken }, }); -socket.emit('subscribe', relayId, resourceId); +socket.on('connect', () => { + socket.emit('subscribe', relayId, resourceId); +}); socket.on('order-updated', payload => { // payload is the rendered JSON template }); @@ -108,6 +108,20 @@ socket.emit('unsubscribe', relayId, resourceId); - The outbound event name is the relay `socketEvent`. - Placeholders in `messageTemplate` use `{{payload.path}}` against the bus JSON payload. +**Subscribe-only.** Clients cannot publish on `/events/`; modules publish to the Redis bus and relays forward matching messages to subscribed rooms. + +**Authentication and Authorization** are required (`authMiddleware` on subscribe). Authorization is re-checked on a short TTL during delivery; revoked access triggers `leave-room`. + +**Rooms** are server-assigned: `er::`. Conduit has no tenant model and **team ids are not part of room names** — isolation is ReBAC on `User` × `permission` × `resourceType:resourceId`. + +**HA / `localOnly`:** every Router replica subscribes to the bus and emits with `localOnly`, so clients spread across replicas still receive one copy without sticky sessions for delivery. Engine.IO **polling** transport still benefits from sticky sessions or WebSocket-only clients. + +**EventBus self-publish:** the bus ignores this process’s own publishes (signature filter). A relay on a channel the Router publishes will not deliver to local clients for those self-originated messages (typical for module-originated events). + +**Dual delivery warning:** Database `/database/` `change` events and a relay on overlapping bus channels (for example `database:change:*` or CRUD `database:update:*`) can duplicate notifications. CRUD bus payloads include **full documents** — prefer change-stream style payloads when both paths are enabled. + +Use preview to validate templates: `POST /router/event-relays/preview` with `messageTemplate` and `samplePayload` (same renderer as runtime; sample JSON is capped at 256 KiB like inbound bus payloads). + ### Admin API | Method | Path | Description | @@ -115,5 +129,6 @@ socket.emit('unsubscribe', relayId, resourceId); | `GET` | `/router/event-relays` | Paginated list (`skip`, `limit`, `search`) | | `GET` | `/router/event-relays/:id` | Single relay | | `POST` | `/router/event-relays` | Create | -| `PATCH` | `/router/event-relays/:id` | Update | +| `POST` | `/router/event-relays/preview` | Render template against sample JSON | +| `PATCH` | `/router/event-relays/:id` | Update (deactivating evicts rooms) | | `DELETE` | `/router/event-relays/:id` | Delete | diff --git a/modules/router/src/Router.ts b/modules/router/src/Router.ts index 0a3867ed6..78369f6e7 100644 --- a/modules/router/src/Router.ts +++ b/modules/router/src/Router.ts @@ -1,4 +1,4 @@ -import { NextFunction } from 'express'; +import { NextFunction, Response } from 'express'; import { status } from '@grpc/grpc-js'; import { ConduitGrpcSdk, @@ -66,6 +66,11 @@ export default class ConduitDefaultRouter extends ManagedModule { private adminRouter: AdminHandlers; private readonly _routes: string[]; private readonly _globalMiddlewares: string[]; + private readonly _socketGlobalMiddlewareHandlers: (( + req: ConduitRequest, + res: Response, + next: NextFunction, + ) => void)[]; private _grpcRoutes: { [field: string]: RouteT[]; } = {}; @@ -76,12 +81,15 @@ export default class ConduitDefaultRouter extends ManagedModule { private _haInitialized = false; private eventRelayManager: EventRelayManager; private eventsSocket?: ConduitSocket; + private socketsPreviouslyStopped = false; + private securityMiddlewareInitialized = false; constructor(peerManifestRoot?: string) { super('router', peerManifestRoot); this.updateHealth(HealthCheckStatus.UNKNOWN, true); this._routes = []; this._globalMiddlewares = []; + this._socketGlobalMiddlewareHandlers = []; } async onServerStart() { @@ -112,6 +120,12 @@ export default class ConduitDefaultRouter extends ManagedModule { this.eventRelayManager = new EventRelayManager( this.grpcSdk, createEventRelayPusher(data => this._internalRouter.socketPush(data)), + { + getLocalRoomUserIds: (room: string) => + this._internalRouter.getLocalRoomUserIds('/events/', room), + getLocalRoomsWithPrefix: (prefix: string) => + this._internalRouter.getLocalRoomsWithPrefix('/events/', prefix), + }, ); this.adminRouter = new AdminHandlers( this.grpcServer, @@ -133,6 +147,10 @@ export default class ConduitDefaultRouter extends ManagedModule { async onConfig() { const config = ConfigController.getInstance().config; + const shouldRebindSocketGlobals = + config.transports.sockets && + this.socketsPreviouslyStopped && + this.securityMiddlewareInitialized; let atLeastOne = false; if (config.transports.graphql) { this._internalRouter.initGraphQL(); @@ -148,16 +166,24 @@ export default class ConduitDefaultRouter extends ManagedModule { } if (config.transports.sockets) { this._internalRouter.initSockets(); - this.registerEventsNamespace(); await this.eventRelayManager.start(); atLeastOne = true; } else { await this.eventRelayManager?.stop(); this._internalRouter.stopSockets(); + this.socketsPreviouslyStopped = true; } - if (atLeastOne) { + if (atLeastOne && !this.securityMiddlewareInitialized) { this._security.setupMiddlewares(); + this.securityMiddlewareInitialized = true; + } + if (config.transports.sockets) { + if (shouldRebindSocketGlobals) { + this.rebindSocketGlobalMiddlewares(); + } + this.socketsPreviouslyStopped = false; + this.registerEventsNamespace(); } if (!this._sdkRoutes.some(r => r.path === '/ready')) { this.registerRoute(adminRoutes.getReadyRoute()); @@ -335,13 +361,29 @@ export default class ConduitDefaultRouter extends ManagedModule { registerGlobalMiddleware( name: string, - middleware: any, + middleware: (req: ConduitRequest, res: Response, next: NextFunction) => void, socketMiddleware: boolean = false, ) { this._globalMiddlewares.push(name); + if (socketMiddleware) { + this._socketGlobalMiddlewareHandlers.push(middleware); + } this._internalRouter.registerMiddleware(middleware, socketMiddleware); } + private rebindSocketGlobalMiddlewares() { + for (const middleware of this._socketGlobalMiddlewareHandlers) { + this._internalRouter.registerSocketGlobalMiddleware(middleware); + } + } + + async shutdown(): Promise { + if (this.eventRelayManager) { + await this.eventRelayManager.stop(); + } + this.grpcSdk.bus?.quit(); + } + getRegisteredRoutes() { return this._routes; } diff --git a/modules/router/src/admin/event-relays.ts b/modules/router/src/admin/event-relays.ts index 2e56fbd15..442b3efd7 100644 --- a/modules/router/src/admin/event-relays.ts +++ b/modules/router/src/admin/event-relays.ts @@ -10,7 +10,9 @@ import { EventRelay } from '../models/index.js'; import { EventRelayManager } from '../event-relays/EventRelayManager.js'; import { EventRelayInput, validateEventRelayInput } from '../event-relays/validation.js'; import { EventRelayValidationError } from '../event-relays/validationError.js'; +import { renderMessageTemplate } from '../event-relays/template.js'; import { buildSearchQuery, parsePagination } from '../event-relays/search.js'; +import { assertJsonPayloadSize } from '../event-relays/process.js'; export class EventRelayAdmin { constructor(private readonly manager: EventRelayManager) {} @@ -52,6 +54,23 @@ export class EventRelayAdmin { return relay; } + async previewEventRelay(call: ParsedRouterRequest): Promise { + const { messageTemplate, samplePayload } = call.request.params as { + messageTemplate: unknown; + samplePayload: unknown; + }; + try { + assertJsonPayloadSize(samplePayload); + const rendered = renderMessageTemplate(messageTemplate, samplePayload); + return { rendered }; + } catch (err) { + if (err instanceof EventRelayValidationError) { + throw new GrpcError(status.INVALID_ARGUMENT, err.message); + } + throw err; + } + } + async patchEventRelay(call: ParsedRouterRequest): Promise { const existing = await EventRelay.getInstance().findOne({ _id: call.request.params.id, @@ -89,7 +108,18 @@ export class EventRelayAdmin { ...input, messageTemplate: input.messageTemplate as EventRelay['messageTemplate'], }); - await this.manager.notifyChanged(); + const evictRelayIds: string[] = []; + if (!input.active) { + evictRelayIds.push(existing._id); + } else if ( + input.permission !== existing.permission || + input.resourceType !== existing.resourceType + ) { + evictRelayIds.push(existing._id); + } + await this.manager.notifyChanged({ + evictRelayIds: evictRelayIds.length ? evictRelayIds : undefined, + }); return updated!; } @@ -101,7 +131,7 @@ export class EventRelayAdmin { throw new GrpcError(status.NOT_FOUND, 'Event relay not found'); } await EventRelay.getInstance().deleteOne({ _id: existing._id }); - await this.manager.notifyChanged(); + await this.manager.notifyChanged({ evictRelayIds: [existing._id] }); return { message: 'Event relay deleted' }; } } diff --git a/modules/router/src/admin/index.ts b/modules/router/src/admin/index.ts index 0c3231378..e943c25fa 100644 --- a/modules/router/src/admin/index.ts +++ b/modules/router/src/admin/index.ts @@ -242,6 +242,21 @@ export class AdminHandlers { }), this.eventRelayAdmin.deleteEventRelay.bind(this.eventRelayAdmin), ); + this.routingManager.route( + { + path: '/event-relays/preview', + action: ConduitRouteActions.POST, + description: `Renders a relay message template against sample bus JSON (same engine as runtime).`, + bodyParams: { + messageTemplate: ConduitJson.Required, + samplePayload: ConduitJson.Required, + }, + }, + new ConduitRouteReturnDefinition('PreviewEventRelay', { + rendered: ConduitJson.Required, + }), + this.eventRelayAdmin.previewEventRelay.bind(this.eventRelayAdmin), + ); this.routingManager.registerRoutes(); } } diff --git a/modules/router/src/event-relays/EventRelayManager.ts b/modules/router/src/event-relays/EventRelayManager.ts index 80cb809b7..905b0180e 100644 --- a/modules/router/src/event-relays/EventRelayManager.ts +++ b/modules/router/src/event-relays/EventRelayManager.ts @@ -3,70 +3,170 @@ import { EventRelay } from '../models/index.js'; import { EVENT_RELAY_REFRESH_CHANNEL, EVENT_RELAY_SUBSCRIBER_PREFIX, + RECONCILE_INTERVAL_MS, + RELAY_REBAC_TTL_MS, } from './constants.js'; import { groupRelaysByChannel, planChannelSubscriptions } from './channels.js'; -import { buildRelayEmissions, parseBusPayload } from './process.js'; +import { parseBusPayload } from './process.js'; import { EventRelayPusher } from './push.js'; +import { compileRelay, CompiledRelay } from './compile.js'; +import { checkRebacBatch, RelayRebacCache } from './rebacCache.js'; +import { eventRelayRoomPrefix } from './rooms.js'; +import { removeSubscriptionsForRelay } from './subscriptions.js'; export type { EventRelayPusher } from './push.js'; export { createEventRelayPusher } from './push.js'; +export type EventRelaySocketAccess = { + getLocalRoomUserIds: (room: string) => Promise; + getLocalRoomsWithPrefix: (prefix: string) => Promise; +}; + +type RefreshPayload = { + evictRelayIds?: string[]; +}; + export class EventRelayManager { - private readonly relaysByChannel = new Map(); + private readonly relaysById = new Map(); + private readonly relaysByChannel = new Map(); private readonly subscribedChannels = new Set(); + private readonly rebacCache = new RelayRebacCache(RELAY_REBAC_TTL_MS); private started = false; + private reconcilePending = false; + private reconciling = false; + private reconcileTimer?: NodeJS.Timeout; constructor( private readonly grpcSdk: ConduitGrpcSdk, private readonly push: EventRelayPusher, + private readonly sockets: EventRelaySocketAccess, ) {} async start(): Promise { if (!this.started) { this.grpcSdk.bus?.subscribe( EVENT_RELAY_REFRESH_CHANNEL, - () => { - this.reconcile().catch(err => { - ConduitGrpcSdk.Logger.error(err as Error); - }); + message => { + void this.onRefreshMessage(message); }, 'router-event-relays-refresh', ); + this.reconcileTimer = setInterval(() => { + void this.reconcile(); + }, RECONCILE_INTERVAL_MS); this.started = true; } await this.reconcile(); } async stop(): Promise { + if (this.reconcileTimer) { + clearInterval(this.reconcileTimer); + this.reconcileTimer = undefined; + } for (const channel of [...this.subscribedChannels]) { this.grpcSdk.bus?.unsubscribe(`${EVENT_RELAY_SUBSCRIBER_PREFIX}${channel}`); this.subscribedChannels.delete(channel); } this.relaysByChannel.clear(); + this.relaysById.clear(); + this.rebacCache.clear(); if (this.started) { this.grpcSdk.bus?.unsubscribe('router-event-relays-refresh'); this.started = false; } + ConduitGrpcSdk.Metrics?.set('event_relays_active_total', 0); + ConduitGrpcSdk.Metrics?.set('event_relays_subscribed_channels_total', 0); } - async notifyChanged(): Promise { + async notifyChanged(options?: { evictRelayIds?: string[] }): Promise { + if (options?.evictRelayIds?.length) { + for (const relayId of options.evictRelayIds) { + await this.evictRelayRooms(relayId); + } + } if (this.started) { await this.reconcile(); } - this.grpcSdk.bus?.publish(EVENT_RELAY_REFRESH_CHANNEL, ''); + const payload: RefreshPayload = { + evictRelayIds: options?.evictRelayIds ?? [], + }; + this.grpcSdk.bus?.publish(EVENT_RELAY_REFRESH_CHANNEL, JSON.stringify(payload)); + } + + async evictRelayRooms(relayId: string): Promise { + const prefix = eventRelayRoomPrefix(relayId); + const rooms = await this.sockets.getLocalRoomsWithPrefix(prefix); + if (rooms.length === 0) { + removeSubscriptionsForRelay(relayId); + return; + } + await this.push('leave-room', undefined, rooms); + removeSubscriptionsForRelay(relayId); + } + + getActiveRelay(id: string): EventRelay | undefined { + return this.relaysById.get(id); + } + + private async onRefreshMessage(raw: string): Promise { + let payload: RefreshPayload = {}; + if (raw.trim()) { + try { + payload = JSON.parse(raw) as RefreshPayload; + } catch { + payload = {}; + } + } + if (payload.evictRelayIds?.length) { + for (const relayId of payload.evictRelayIds) { + await this.evictRelayRooms(relayId); + } + } + await this.reconcile(); } async reconcile(): Promise { + this.reconcilePending = true; + if (this.reconciling) { + return; + } + this.reconciling = true; + try { + while (this.reconcilePending) { + this.reconcilePending = false; + await this.runReconcile(); + } + } finally { + this.reconciling = false; + } + } + + private async runReconcile(): Promise { + const previousIds = new Set(this.relaysById.keys()); const relays = await EventRelay.getInstance().findMany({ active: true }); - const next = groupRelaysByChannel(relays); + const nextByChannel = groupRelaysByChannel(relays); const { toSubscribe, toUnsubscribe } = planChannelSubscriptions( this.subscribedChannels, - next.keys(), + nextByChannel.keys(), ); + this.relaysById.clear(); this.relaysByChannel.clear(); - for (const [channel, list] of next) { - this.relaysByChannel.set(channel, list); + for (const relay of relays) { + this.relaysById.set(relay._id, relay); + } + for (const [channel, channelRelays] of nextByChannel) { + this.relaysByChannel.set( + channel, + channelRelays.map(relay => compileRelay(relay)), + ); + } + + for (const id of previousIds) { + if (!this.relaysById.has(id)) { + await this.evictRelayRooms(id); + } } for (const channel of toUnsubscribe) { @@ -75,21 +175,27 @@ export class EventRelayManager { } for (const channel of toSubscribe) { - this.grpcSdk.bus?.subscribe( - channel, - message => this.onBusMessage(channel, message), - `${EVENT_RELAY_SUBSCRIBER_PREFIX}${channel}`, - ); - this.subscribedChannels.add(channel); + try { + await this.grpcSdk.bus?.subscribeAck( + channel, + message => this.onBusMessage(channel, message), + `${EVENT_RELAY_SUBSCRIBER_PREFIX}${channel}`, + ); + this.subscribedChannels.add(channel); + } catch (err) { + ConduitGrpcSdk.Logger.error( + `Event relay failed to subscribe to bus channel ${channel}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } } - } - getActiveRelay(id: string): EventRelay | undefined { - for (const relays of this.relaysByChannel.values()) { - const match = relays.find(relay => relay._id === id); - if (match) return match; - } - return undefined; + ConduitGrpcSdk.Metrics?.set('event_relays_active_total', this.relaysById.size); + ConduitGrpcSdk.Metrics?.set( + 'event_relays_subscribed_channels_total', + this.subscribedChannels.size, + ); } private onBusMessage(channel: string, message: string): void { @@ -103,6 +209,13 @@ export class EventRelayManager { payload = parseBusPayload(message); } catch (err) { ConduitGrpcSdk.Metrics?.increment('event_relays_failed_total'); + if ( + err instanceof Error && + err.message.includes('exceeds') && + err.message.includes('bytes') + ) { + ConduitGrpcSdk.Metrics?.increment('event_relays_inbound_dropped_total'); + } ConduitGrpcSdk.Logger.error( `Event relay failed to parse payload for ${channel}: ${ err instanceof Error ? err.message : String(err) @@ -111,27 +224,74 @@ export class EventRelayManager { return; } - const { emissions, failures } = buildRelayEmissions(relays, payload); - for (const failure of failures) { + for (const relay of relays) { + void this.emitCompiledRelay(relay, payload, channel); + } + } + + private async emitCompiledRelay( + relay: CompiledRelay, + payload: unknown, + channel: string, + ): Promise { + let room: string; + let data: unknown; + let resourceId: string; + try { + ({ room, data, resourceId } = relay.buildEmission(payload)); + } catch (err) { ConduitGrpcSdk.Metrics?.increment('event_relays_failed_total'); ConduitGrpcSdk.Logger.warn( - `Event relay ${failure.relayId} skipped on ${failure.busEvent}: ${failure.reason}`, + `Event relay ${relay.relayId} skipped on ${relay.busEvent}: ${ + err instanceof Error ? err.message : String(err) + }`, ); + return; } - for (const emission of emissions) { - this.push(emission.socketEvent, emission.data, [emission.room]).then( - () => { - ConduitGrpcSdk.Metrics?.increment('event_relays_emitted_total'); - }, - err => { - ConduitGrpcSdk.Metrics?.increment('event_relays_failed_total'); - ConduitGrpcSdk.Logger.error( - `Event relay ${emission.relayId} emit failed on ${channel}: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - }, + const userIds = await this.sockets.getLocalRoomUserIds(room); + if (userIds.length === 0) { + ConduitGrpcSdk.Metrics?.increment('event_relays_empty_room_total'); + return; + } + + const decisions = await checkRebacBatch( + this.rebacCache, + this.grpcSdk, + userIds, + relay.permission, + relay.resourceType, + resourceId, + ); + + const allowedUsers: string[] = []; + for (const userId of userIds) { + const decision = decisions.get(userId) ?? 'unavailable'; + if (decision === 'allow') { + allowedUsers.push(userId); + } else if (decision === 'deny') { + await this.push('leave-room', undefined, [room], [userId]); + ConduitGrpcSdk.Metrics?.increment('event_relay_subscriptions_denied_total'); + } + } + + if (allowedUsers.length === 0) { + return; + } + + try { + const emitted = await this.push(relay.socketEvent, data, [room], allowedUsers); + if (emitted) { + ConduitGrpcSdk.Metrics?.increment('event_relays_emitted_total'); + } else { + ConduitGrpcSdk.Metrics?.increment('event_relays_empty_room_total'); + } + } catch (err) { + ConduitGrpcSdk.Metrics?.increment('event_relays_failed_total'); + ConduitGrpcSdk.Logger.error( + `Event relay ${relay.relayId} emit failed on ${channel}: ${ + err instanceof Error ? err.message : String(err) + }`, ); } } diff --git a/modules/router/src/event-relays/EventRelaySockets.ts b/modules/router/src/event-relays/EventRelaySockets.ts index 32e5fec2a..d9fe6f88c 100644 --- a/modules/router/src/event-relays/EventRelaySockets.ts +++ b/modules/router/src/event-relays/EventRelaySockets.ts @@ -1,11 +1,27 @@ import { status } from '@grpc/grpc-js'; import { ConduitGrpcSdk, GrpcError, TYPE } from '@conduitplatform/grpc-sdk'; import { ConduitSocket, ConduitSocketEvent } from '@conduitplatform/hermes'; -import { EVENTS_NAMESPACE } from './constants.js'; +import { + EVENTS_NAMESPACE, + MAX_SUBSCRIBE_PER_MINUTE, +} from './constants.js'; import { EventRelayManager } from './EventRelayManager.js'; import { eventRelayRoom } from './rooms.js'; import { validateResourceId } from './validation.js'; import { authorizeRelaySubscription, toSubscriptionError } from './authorize.js'; +import { reauthorizeRecoveredSubscriptions } from './recovery.js'; +import { + releaseSocketSubscriptions, + removeSubscription, + trackSubscription, + _clearEventRelaySubscriptionStateForTests, +} from './subscriptions.js'; +import { + persistRelaySubscriptionOnContext, + removeRelaySubscriptionFromContext, +} from './relaySocketData.js'; + +const subscribeTimestamps = new Map(); export function createEventsSocket( grpcSdk: ConduitGrpcSdk, @@ -20,16 +36,40 @@ export function createEventsSocket( events.set('disconnect', { name: 'disconnect', - handler: async () => ({ event: 'leave-room', rooms: [] }), + handler: async request => { + const userId = request.context?.user?._id as string | undefined; + releaseSocketSubscriptions(request.socketId, userId); + subscribeTimestamps.delete(request.socketId); + return { event: 'leave-room', rooms: [] }; + }, }); events.set('subscribe', { name: 'subscribe', params: [TYPE.String, TYPE.String], handler: async request => { + assertSubscribeRateLimit(request.socketId); const userId = request.context?.user?._id as string | undefined; + if (!userId) { + throw new GrpcError(status.UNAUTHENTICATED, 'Authentication required'); + } const [relayId, resourceId] = request.params ?? []; + const validatedResourceId = validateResourceId(resourceId); const room = await authorizeOrThrow(grpcSdk, manager, userId, relayId, resourceId); + try { + trackSubscription( + request.socketId, + userId, + String(relayId), + validatedResourceId, + ); + persistRelaySubscriptionOnContext(request.context, String(relayId), validatedResourceId); + } catch (err) { + throw new GrpcError( + status.RESOURCE_EXHAUSTED, + err instanceof Error ? err.message : 'Subscription limit exceeded', + ); + } return { event: 'join-room', rooms: [room] }; }, }); @@ -38,12 +78,15 @@ export function createEventsSocket( name: 'unsubscribe', params: [TYPE.String, TYPE.String], handler: async request => { + const userId = request.context?.user?._id as string | undefined; const [relayId, resourceId] = request.params ?? []; if (typeof relayId !== 'string' || relayId.trim() === '') { throw new GrpcError(status.INVALID_ARGUMENT, 'Relay ID is required'); } try { const validatedResourceId = validateResourceId(resourceId); + removeSubscription(request.socketId, userId, relayId, validatedResourceId); + removeRelaySubscriptionFromContext(request.context, relayId, validatedResourceId); return { event: 'leave-room', rooms: [eventRelayRoom(relayId, validatedResourceId)], @@ -60,11 +103,26 @@ export function createEventsSocket( name: 'eventRelays', description: 'Declarative bus-to-socket event relays', middlewares: ['authMiddleware'], + onRecovered: async request => + reauthorizeRecoveredSubscriptions(grpcSdk, manager, request), }, events, ); } +function assertSubscribeRateLimit(socketId: string): void { + const now = Date.now(); + const windowStart = now - 60_000; + const timestamps = (subscribeTimestamps.get(socketId) ?? []).filter( + t => t >= windowStart, + ); + if (timestamps.length >= MAX_SUBSCRIBE_PER_MINUTE) { + throw new GrpcError(status.RESOURCE_EXHAUSTED, 'Subscribe rate limit exceeded'); + } + timestamps.push(now); + subscribeTimestamps.set(socketId, timestamps); +} + async function authorizeOrThrow( grpcSdk: ConduitGrpcSdk, manager: EventRelayManager, @@ -94,3 +152,9 @@ function toGrpcError(err: unknown): GrpcError { } export { authorizeRelaySubscription } from './authorize.js'; + +/** @internal test helper */ +export function _clearEventRelaySocketStateForTests(): void { + _clearEventRelaySubscriptionStateForTests(); + subscribeTimestamps.clear(); +} diff --git a/modules/router/src/event-relays/authorize.ts b/modules/router/src/event-relays/authorize.ts index 852cf5595..8de7cc005 100644 --- a/modules/router/src/event-relays/authorize.ts +++ b/modules/router/src/event-relays/authorize.ts @@ -62,7 +62,6 @@ export async function authorizeRelaySubscription( } if (!grpcSdk.authorization || !grpcSdk.isAvailable('authorization')) { - onDenied?.(); throw new RelaySubscriptionError(status.UNAVAILABLE, 'Authorization is unavailable'); } @@ -75,7 +74,6 @@ export async function authorizeRelaySubscription( }); allowed = decision.allow; } catch { - onDenied?.(); throw new RelaySubscriptionError(status.UNAVAILABLE, 'Authorization check failed'); } diff --git a/modules/router/src/event-relays/compile.ts b/modules/router/src/event-relays/compile.ts new file mode 100644 index 000000000..bfe636592 --- /dev/null +++ b/modules/router/src/event-relays/compile.ts @@ -0,0 +1,50 @@ +import { requireOwnPath } from './path.js'; +import { eventRelayRoom } from './rooms.js'; +import { renderMessageTemplate } from './template.js'; +import { validateResourceId } from './validation.js'; + +export type RelayCompileInput = { + _id: string; + busEvent: string; + socketEvent: string; + resourceIdPath: string; + messageTemplate: unknown; + permission: string; + resourceType: string; +}; + +export type CompiledRelay = { + relayId: string; + busEvent: string; + socketEvent: string; + permission: string; + resourceType: string; + buildEmission: (payload: unknown) => { + room: string; + data: unknown; + resourceId: string; + }; +}; + +export function compileRelay(relay: RelayCompileInput): CompiledRelay { + const resourceIdPath = relay.resourceIdPath; + const template = relay.messageTemplate; + return { + relayId: relay._id, + busEvent: relay.busEvent, + socketEvent: relay.socketEvent, + permission: relay.permission, + resourceType: relay.resourceType, + buildEmission: (payload: unknown) => { + const resourceId = validateResourceId( + requireOwnPath(payload, resourceIdPath, 'Resource ID path'), + ); + const data = renderMessageTemplate(template, payload); + return { + room: eventRelayRoom(relay._id, resourceId), + data, + resourceId, + }; + }, + }; +} diff --git a/modules/router/src/event-relays/constants.ts b/modules/router/src/event-relays/constants.ts index e283da0dd..ce80f6194 100644 --- a/modules/router/src/event-relays/constants.ts +++ b/modules/router/src/event-relays/constants.ts @@ -15,6 +15,12 @@ export const MAX_RESOURCE_ID_LENGTH = 128; export const MAX_RESOURCE_ID_PATH_LENGTH = 128; export const MAX_PERMISSION_LENGTH = 64; +export const MAX_INBOUND_BUS_BYTES = 256 * 1024; +export const RECONCILE_INTERVAL_MS = 30_000; +export const MAX_SUBSCRIBE_PER_MINUTE = 30; +export const MAX_ROOMS_PER_SOCKET = 32; +export const RELAY_REBAC_TTL_MS = 12_000; + export const FORBIDDEN_PATH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']); export const RESERVED_SOCKET_EVENTS = new Set([ diff --git a/modules/router/src/event-relays/follow-up.test.ts b/modules/router/src/event-relays/follow-up.test.ts new file mode 100644 index 000000000..fea6d438e --- /dev/null +++ b/modules/router/src/event-relays/follow-up.test.ts @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + releaseSocketSubscriptions, + removeSubscriptionsForRelay, + subscriptionsForRecoveredRooms, + subscriptionsForUser, + trackSubscription, + _clearEventRelaySubscriptionStateForTests, +} from './subscriptions.js'; +import { eventRelayRoom } from './rooms.js'; +import { reauthorizeRecoveredSubscriptions } from './recovery.js'; +import { RelayLookup } from './authorize.js'; + +describe('event relay subscription tracking', () => { + it('clears user map entries when the only socket disconnects', () => { + _clearEventRelaySubscriptionStateForTests(); + trackSubscription('socket-a', 'user-1', 'relay-1', 'order-1'); + releaseSocketSubscriptions('socket-a', 'user-1'); + assert.deepEqual(subscriptionsForUser('user-1'), []); + }); + + it('keeps user map entries when another socket still holds the subscription', () => { + _clearEventRelaySubscriptionStateForTests(); + trackSubscription('socket-a', 'user-1', 'relay-1', 'order-1'); + trackSubscription('socket-b', 'user-1', 'relay-1', 'order-1'); + releaseSocketSubscriptions('socket-a', 'user-1'); + assert.deepEqual(subscriptionsForUser('user-1'), [ + { relayId: 'relay-1', resourceId: 'order-1' }, + ]); + }); + + it('clears relay subscriptions on eviction', () => { + _clearEventRelaySubscriptionStateForTests(); + trackSubscription('socket-a', 'user-1', 'relay-1', 'order-1'); + removeSubscriptionsForRelay('relay-1'); + assert.deepEqual(subscriptionsForUser('user-1'), []); + }); +}); + +describe('recovery re-authorization', () => { + const room = eventRelayRoom('relay-1', 'order-1'); + const relay = { + _id: 'relay-1', + permission: 'read', + resourceType: 'Order', + }; + + it('keeps restored rooms when Authorization is unavailable', async () => { + _clearEventRelaySubscriptionStateForTests(); + trackSubscription('old-socket', 'user-1', 'relay-1', 'order-1'); + releaseSocketSubscriptions('old-socket', 'user-1'); + + const manager = { + getActiveRelay: (id: string) => (id === 'relay-1' ? relay : undefined), + } as unknown as RelayLookup; + + const grpcSdk = { + isAvailable: () => false, + authorization: null, + }; + + const result = await reauthorizeRecoveredSubscriptions(grpcSdk, manager, { + socketId: 'new-socket', + context: { + user: { _id: 'user-1' }, + eventRelaySubs: [{ relayId: 'relay-1', resourceId: 'order-1' }], + }, + recoveredRooms: [room], + }); + + assert.deepEqual(result, { event: 'join-room', rooms: [] }); + assert.deepEqual(subscriptionsForUser('user-1'), [ + { relayId: 'relay-1', resourceId: 'order-1' }, + ]); + }); + + it('leaves restored rooms on permission deny', async () => { + _clearEventRelaySubscriptionStateForTests(); + trackSubscription('old-socket', 'user-1', 'relay-1', 'order-1'); + releaseSocketSubscriptions('old-socket', 'user-1'); + + const manager = { + getActiveRelay: (id: string) => (id === 'relay-1' ? relay : undefined), + } as unknown as RelayLookup; + + const grpcSdk = { + isAvailable: () => true, + authorization: { + can: async () => ({ allow: false }), + }, + }; + + const result = await reauthorizeRecoveredSubscriptions(grpcSdk, manager, { + socketId: 'new-socket', + context: { + user: { _id: 'user-1' }, + eventRelaySubs: [{ relayId: 'relay-1', resourceId: 'order-1' }], + }, + recoveredRooms: [room], + }); + + assert.deepEqual(result, { event: 'leave-room', rooms: [room] }); + assert.deepEqual(subscriptionsForUser('user-1'), []); + }); + + it('does not re-auth rooms outside the recovered socket session', async () => { + _clearEventRelaySubscriptionStateForTests(); + trackSubscription('old-socket', 'user-1', 'relay-1', 'order-1'); + trackSubscription('old-socket', 'user-1', 'relay-2', 'order-2'); + releaseSocketSubscriptions('old-socket', 'user-1'); + + const manager = { + getActiveRelay: (id: string) => + id === 'relay-2' + ? { _id: 'relay-2', permission: 'read', resourceType: 'Order' } + : undefined, + } as unknown as RelayLookup; + + let canCalls = 0; + const grpcSdk = { + isAvailable: () => true, + authorization: { + can: async () => { + canCalls++; + return { allow: false }; + }, + }, + }; + + const otherRoom = eventRelayRoom('relay-2', 'order-2'); + await reauthorizeRecoveredSubscriptions(grpcSdk, manager, { + socketId: 'new-socket', + context: { + user: { _id: 'user-1' }, + eventRelaySubs: [{ relayId: 'relay-2', resourceId: 'order-2' }], + }, + recoveredRooms: [otherRoom], + }); + + assert.equal(canCalls, 1); + assert.deepEqual(subscriptionsForUser('user-1'), []); + }); + + it('re-auths the first user after a second user subscribed to the same room', async () => { + _clearEventRelaySubscriptionStateForTests(); + trackSubscription('socket-a', 'user-a', 'relay-1', 'order-1'); + trackSubscription('socket-b', 'user-b', 'relay-1', 'order-1'); + releaseSocketSubscriptions('socket-b', 'user-b'); + + let checkedSubject = ''; + const manager = { + getActiveRelay: (id: string) => (id === 'relay-1' ? relay : undefined), + } as unknown as RelayLookup; + const grpcSdk = { + isAvailable: () => true, + authorization: { + can: async (request: { subject: string }) => { + checkedSubject = request.subject; + return { allow: true }; + }, + }, + }; + + await reauthorizeRecoveredSubscriptions(grpcSdk, manager, { + socketId: 'socket-a-new', + context: { user: { _id: 'user-a' } }, + recoveredRooms: [room], + }); + + assert.equal(checkedSubject, 'User:user-a'); + }); + + it('prefers socket.data subscriptions when the room map was pruned on disconnect', () => { + _clearEventRelaySubscriptionStateForTests(); + trackSubscription('socket-a', 'user-a', 'relay-1', 'order-1'); + releaseSocketSubscriptions('socket-a', 'user-a'); + assert.deepEqual(subscriptionsForRecoveredRooms([room]), []); + assert.deepEqual( + subscriptionsForRecoveredRooms([room], [ + { relayId: 'relay-1', resourceId: 'order-1' }, + ]), + [{ relayId: 'relay-1', resourceId: 'order-1' }], + ); + }); +}); diff --git a/modules/router/src/event-relays/interventions.test.ts b/modules/router/src/event-relays/interventions.test.ts new file mode 100644 index 000000000..f9e37f644 --- /dev/null +++ b/modules/router/src/event-relays/interventions.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { MAX_INBOUND_BUS_BYTES } from './constants.js'; +import { parseBusPayload } from './process.js'; +import { renderMessageTemplate } from './template.js'; + +describe('parseBusPayload inbound cap', () => { + it('drops payloads larger than 256KiB before JSON.parse', () => { + const oversized = JSON.stringify({ blob: 'x'.repeat(MAX_INBOUND_BUS_BYTES) }); + assert.throws(() => parseBusPayload(oversized), /exceeds/); + }); +}); + +describe('preview renderer parity', () => { + it('renders templates the same as runtime processing', () => { + const payload = { documentId: 'doc-1', nested: { value: 2 } }; + const template = { id: '{{payload.documentId}}', n: '{{payload.nested.value}}' }; + assert.deepEqual(renderMessageTemplate(template, payload), { + id: 'doc-1', + n: 2, + }); + }); +}); diff --git a/modules/router/src/event-relays/process.test.ts b/modules/router/src/event-relays/process.test.ts index cf3c1c10b..c811bcec5 100644 --- a/modules/router/src/event-relays/process.test.ts +++ b/modules/router/src/event-relays/process.test.ts @@ -69,6 +69,7 @@ describe('createEventRelayPusher', () => { const calls: unknown[] = []; const push = createEventRelayPusher(async data => { calls.push(data); + return true; }); await push('order-updated', { id: '1' }, ['room-1']); assert.deepEqual(calls, [ @@ -79,6 +80,8 @@ describe('createEventRelayPusher', () => { rooms: ['room-1'], namespace: EVENTS_NAMESPACE, localOnly: true, + skipEmptyRooms: true, + boundedEmit: true, }, ]); }); diff --git a/modules/router/src/event-relays/process.ts b/modules/router/src/event-relays/process.ts index 858c9f6d0..ebe6ec1ec 100644 --- a/modules/router/src/event-relays/process.ts +++ b/modules/router/src/event-relays/process.ts @@ -3,6 +3,7 @@ import { eventRelayRoom } from './rooms.js'; import { renderMessageTemplate } from './template.js'; import { validateResourceId } from './validation.js'; import { EventRelayValidationError } from './validationError.js'; +import { MAX_INBOUND_BUS_BYTES } from './constants.js'; export type RelayProcessInput = { _id: string; @@ -31,10 +32,16 @@ export type ProcessResult = { failures: RelayFailure[]; }; -export function parseBusPayload(rawMessage: string): unknown { +export function parseBusPayload( + rawMessage: string, + maxBytes: number = MAX_INBOUND_BUS_BYTES, +): unknown { if (typeof rawMessage !== 'string' || rawMessage.trim() === '') { throw new EventRelayValidationError('Bus payload is empty'); } + if (Buffer.byteLength(rawMessage, 'utf8') > maxBytes) { + throw new EventRelayValidationError(`Bus payload exceeds ${maxBytes} bytes`); + } try { return JSON.parse(rawMessage); } catch { @@ -42,6 +49,21 @@ export function parseBusPayload(rawMessage: string): unknown { } } +export function assertJsonPayloadSize( + payload: unknown, + maxBytes: number = MAX_INBOUND_BUS_BYTES, +): void { + let serialized: string; + try { + serialized = JSON.stringify(payload); + } catch { + throw new EventRelayValidationError('Sample payload must be valid JSON'); + } + if (Buffer.byteLength(serialized, 'utf8') > maxBytes) { + throw new EventRelayValidationError(`Sample payload exceeds ${maxBytes} bytes`); + } +} + export function buildRelayEmissions( relays: RelayProcessInput[], payload: unknown, diff --git a/modules/router/src/event-relays/push.ts b/modules/router/src/event-relays/push.ts index 79d9911b2..e4bed5062 100644 --- a/modules/router/src/event-relays/push.ts +++ b/modules/router/src/event-relays/push.ts @@ -4,7 +4,8 @@ export type EventRelayPusher = ( event: string, data: unknown, rooms: string[], -) => Promise; + receivers?: string[], +) => Promise; export type SocketPushFn = (data: { event: string; @@ -13,16 +14,20 @@ export type SocketPushFn = (data: { rooms: string[]; namespace: string; localOnly?: boolean; -}) => Promise; + skipEmptyRooms?: boolean; + boundedEmit?: boolean; +}) => Promise; export function createEventRelayPusher(socketPush: SocketPushFn): EventRelayPusher { - return (event, data, rooms) => + return async (event, data, rooms, receivers = []) => socketPush({ event, data, - receivers: [], + receivers, rooms, namespace: EVENTS_NAMESPACE, localOnly: true, + skipEmptyRooms: event !== 'leave-room', + boundedEmit: event !== 'leave-room', }); } diff --git a/modules/router/src/event-relays/rebacCache.test.ts b/modules/router/src/event-relays/rebacCache.test.ts new file mode 100644 index 000000000..df20365d5 --- /dev/null +++ b/modules/router/src/event-relays/rebacCache.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { RelayRebacCache } from './rebacCache.js'; + +describe('RelayRebacCache', () => { + it('denies after TTL when authorization revokes access', async () => { + let allow = true; + const cache = new RelayRebacCache(10); + const grpcSdk = { + isAvailable: () => true, + authorization: { + can: async () => ({ allow }), + }, + }; + assert.equal( + await cache.check(grpcSdk as never, 'user-1', 'read', 'Order', 'order-1'), + 'allow', + ); + allow = false; + assert.equal( + await cache.check(grpcSdk as never, 'user-1', 'read', 'Order', 'order-1'), + 'allow', + ); + await new Promise(resolve => setTimeout(resolve, 15)); + assert.equal( + await cache.check(grpcSdk as never, 'user-1', 'read', 'Order', 'order-1'), + 'deny', + ); + }); + + it('returns unavailable without caching when Authorization is down', async () => { + const cache = new RelayRebacCache(10_000); + const grpcSdk = { isAvailable: () => false, authorization: null }; + assert.equal( + await cache.check(grpcSdk as never, 'user-1', 'read', 'Order', 'order-1'), + 'unavailable', + ); + assert.equal( + await cache.check(grpcSdk as never, 'user-1', 'read', 'Order', 'order-1'), + 'unavailable', + ); + }); +}); diff --git a/modules/router/src/event-relays/rebacCache.ts b/modules/router/src/event-relays/rebacCache.ts new file mode 100644 index 000000000..a2d458300 --- /dev/null +++ b/modules/router/src/event-relays/rebacCache.ts @@ -0,0 +1,108 @@ +type RebacSdk = { + isAvailable: (module: string) => boolean; + authorization?: { + can: (request: { + subject: string; + actions: string[]; + resource: string; + }) => Promise<{ allow: boolean }>; + } | null; +}; + +export type RebacDecision = 'allow' | 'deny' | 'unavailable'; + +const DEFAULT_TTL_MS = 12_000; +const DEFAULT_MAX_ENTRIES = 10_000; + +type CacheEntry = { + decision: 'allow' | 'deny'; + expiresAt: number; +}; + +export class RelayRebacCache { + private readonly entries = new Map(); + constructor( + private readonly ttlMs: number = DEFAULT_TTL_MS, + private readonly maxEntries: number = DEFAULT_MAX_ENTRIES, + ) {} + + async check( + grpcSdk: RebacSdk, + userId: string, + permission: string, + resourceType: string, + resourceId: string, + ): Promise { + const resource = `${resourceType}:${resourceId}`; + const key = `${userId}:${permission}:${resource}`; + const now = Date.now(); + this.sweepExpired(now); + const cached = this.entries.get(key); + if (cached && cached.expiresAt > now) { + return cached.decision; + } + if (!grpcSdk.authorization || !grpcSdk.isAvailable('authorization')) { + return 'unavailable'; + } + try { + const decision = await grpcSdk.authorization.can({ + subject: `User:${userId}`, + actions: [permission], + resource, + }); + this.set(key, decision.allow ? 'allow' : 'deny', now); + return decision.allow ? 'allow' : 'deny'; + } catch { + return 'unavailable'; + } + } + + clear(): void { + this.entries.clear(); + } + + private set(key: string, decision: 'allow' | 'deny', now: number): void { + if (this.entries.size >= this.maxEntries && !this.entries.has(key)) { + const firstKey = this.entries.keys().next().value; + if (firstKey) { + this.entries.delete(firstKey); + } + } + this.entries.set(key, { decision, expiresAt: now + this.ttlMs }); + } + + private sweepExpired(now: number): void { + for (const [key, entry] of this.entries) { + if (entry.expiresAt <= now) { + this.entries.delete(key); + } + } + } +} + +export async function checkRebacBatch( + cache: RelayRebacCache, + grpcSdk: RebacSdk, + userIds: string[], + permission: string, + resourceType: string, + resourceId: string, + concurrency = 8, +): Promise> { + const results = new Map(); + let index = 0; + async function worker(): Promise { + while (index < userIds.length) { + const userId = userIds[index++]; + results.set( + userId, + await cache.check(grpcSdk, userId, permission, resourceType, resourceId), + ); + } + } + const workers = Array.from({ length: Math.min(concurrency, userIds.length) }, () => + worker(), + ); + await Promise.all(workers); + return results; +} diff --git a/modules/router/src/event-relays/recovery.ts b/modules/router/src/event-relays/recovery.ts new file mode 100644 index 000000000..ceb6c4217 --- /dev/null +++ b/modules/router/src/event-relays/recovery.ts @@ -0,0 +1,85 @@ +import { status } from '@grpc/grpc-js'; +import { ConduitGrpcSdk } from '@conduitplatform/grpc-sdk'; +import { + authorizeRelaySubscription, + RelaySubscriptionError, + RelayLookup, + toSubscriptionError, +} from './authorize.js'; +import { eventRelayRoom } from './rooms.js'; +import { + removeSubscription, + subscriptionsForRecoveredRooms, + trackSubscription, +} from './subscriptions.js'; +import { relaySubscriptionsFromContext, persistRelaySubscriptionOnContext, removeRelaySubscriptionFromContext } from './relaySocketData.js'; + +type RelayAuthorizationSdk = Parameters[0]; + +export async function reauthorizeRecoveredSubscriptions( + grpcSdk: RelayAuthorizationSdk, + manager: RelayLookup, + request: { + socketId: string; + context?: { user?: { _id?: string } } & Record; + recoveredRooms?: string[]; + }, +): Promise< + { event: 'join-room'; rooms: string[] } | { event: 'leave-room'; rooms: string[] } +> { + const userId = request.context?.user?._id as string | undefined; + if (!userId) { + return { event: 'leave-room', rooms: [] }; + } + + const recoveredRooms = request.recoveredRooms ?? []; + const subs = subscriptionsForRecoveredRooms( + recoveredRooms, + relaySubscriptionsFromContext(request.context), + ); + const leaveRooms: string[] = []; + + for (const sub of subs) { + const room = eventRelayRoom(sub.relayId, sub.resourceId); + try { + await authorizeRelaySubscription( + grpcSdk, + manager, + userId, + sub.relayId, + sub.resourceId, + () => { + ConduitGrpcSdk.Metrics?.increment('event_relay_subscriptions_denied_total'); + }, + ); + trackSubscription(request.socketId, userId, sub.relayId, sub.resourceId); + persistRelaySubscriptionOnContext(request.context, sub.relayId, sub.resourceId); + } catch (err) { + const mapped = + err instanceof RelaySubscriptionError ? err : toSubscriptionError(err); + if (mapped.code === status.UNAVAILABLE) { + trackSubscription(request.socketId, userId, sub.relayId, sub.resourceId); + persistRelaySubscriptionOnContext(request.context, sub.relayId, sub.resourceId); + continue; + } + if ( + mapped.code === status.PERMISSION_DENIED || + mapped.code === status.NOT_FOUND + ) { + removeSubscription(request.socketId, userId, sub.relayId, sub.resourceId); + removeRelaySubscriptionFromContext(request.context, sub.relayId, sub.resourceId); + leaveRooms.push(room); + ConduitGrpcSdk.Metrics?.increment('event_relay_subscriptions_denied_total'); + continue; + } + removeSubscription(request.socketId, userId, sub.relayId, sub.resourceId); + removeRelaySubscriptionFromContext(request.context, sub.relayId, sub.resourceId); + leaveRooms.push(room); + } + } + + if (leaveRooms.length > 0) { + return { event: 'leave-room', rooms: leaveRooms }; + } + return { event: 'join-room', rooms: [] }; +} diff --git a/modules/router/src/event-relays/relaySocketData.ts b/modules/router/src/event-relays/relaySocketData.ts new file mode 100644 index 000000000..63d0eca9c --- /dev/null +++ b/modules/router/src/event-relays/relaySocketData.ts @@ -0,0 +1,65 @@ +import { Indexable } from '@conduitplatform/grpc-sdk'; +import { RelaySubscription } from './subscriptions.js'; + +const CONTEXT_KEY = 'eventRelaySubs'; + +export function relaySubscriptionsFromContext( + context: Indexable | undefined, +): RelaySubscription[] { + if (!context) { + return []; + } + const raw = context[CONTEXT_KEY]; + if (!Array.isArray(raw)) { + return []; + } + const subs: RelaySubscription[] = []; + for (const item of raw) { + if ( + item && + typeof item === 'object' && + typeof (item as RelaySubscription).relayId === 'string' && + typeof (item as RelaySubscription).resourceId === 'string' + ) { + subs.push({ + relayId: (item as RelaySubscription).relayId, + resourceId: (item as RelaySubscription).resourceId, + }); + } + } + return subs; +} + +export function persistRelaySubscriptionOnContext( + context: Indexable | undefined, + relayId: string, + resourceId: string, +): void { + if (!context) { + return; + } + const subs = relaySubscriptionsFromContext(context); + const withoutDup = subs.filter( + sub => !(sub.relayId === relayId && sub.resourceId === resourceId), + ); + withoutDup.push({ relayId, resourceId }); + context[CONTEXT_KEY] = withoutDup; +} + +export function removeRelaySubscriptionFromContext( + context: Indexable | undefined, + relayId: string, + resourceId: string, +): void { + if (!context) { + return; + } + const next = relaySubscriptionsFromContext(context).filter( + sub => !(sub.relayId === relayId && sub.resourceId === resourceId), + ); + if (next.length === 0) { + delete context[CONTEXT_KEY]; + } else { + context[CONTEXT_KEY] = next; + } +} diff --git a/modules/router/src/event-relays/rooms.ts b/modules/router/src/event-relays/rooms.ts index fd5dd54ec..0bf26bf74 100644 --- a/modules/router/src/event-relays/rooms.ts +++ b/modules/router/src/event-relays/rooms.ts @@ -4,3 +4,7 @@ export function eventRelayRoom(relayId: string, resourceId: string): string { const digest = createHash('sha256').update(resourceId).digest('hex'); return `er:${relayId}:${digest}`; } + +export function eventRelayRoomPrefix(relayId: string): string { + return `er:${relayId}:`; +} diff --git a/modules/router/src/event-relays/subscriptions.ts b/modules/router/src/event-relays/subscriptions.ts new file mode 100644 index 000000000..a7a083f56 --- /dev/null +++ b/modules/router/src/event-relays/subscriptions.ts @@ -0,0 +1,224 @@ +import { eventRelayRoom } from './rooms.js'; +import { MAX_ROOMS_PER_SOCKET } from './constants.js'; + +export type RelaySubscription = { + relayId: string; + resourceId: string; +}; + +const RECOVERY_ROOM_MAP_TTL_MS = 120_000; + +const subscriptionsBySocket = new Map(); +const subscriptionsByUser = new Map(); +const subscriptionsByRoom = new Map(); + +function pruneExpiredRoomEntries(now = Date.now()): void { + for (const [room, entry] of subscriptionsByRoom) { + if (entry.expiresAt <= now) { + subscriptionsByRoom.delete(room); + } + } +} + +function touchRoomEntry(relayId: string, resourceId: string): void { + const room = eventRelayRoom(relayId, resourceId); + subscriptionsByRoom.set(room, { + relayId, + resourceId, + expiresAt: Date.now() + RECOVERY_ROOM_MAP_TTL_MS, + }); +} + +function pruneRoomEntryIfUnused(relayId: string, resourceId: string): void { + if (!isSubscriptionTrackedOnAnySocket(relayId, resourceId)) { + subscriptionsByRoom.delete(eventRelayRoom(relayId, resourceId)); + } +} + +export function trackSubscription( + socketId: string, + userId: string, + relayId: string, + resourceId: string, +): void { + const entry = { relayId, resourceId }; + const socketSubs = subscriptionsBySocket.get(socketId) ?? []; + const withoutDup = socketSubs.filter( + sub => !(sub.relayId === relayId && sub.resourceId === resourceId), + ); + withoutDup.push(entry); + if (withoutDup.length > MAX_ROOMS_PER_SOCKET) { + throw new Error(`Cannot subscribe to more than ${MAX_ROOMS_PER_SOCKET} relay rooms`); + } + subscriptionsBySocket.set(socketId, withoutDup); + + const userSubs = subscriptionsByUser.get(userId) ?? []; + const userWithoutDup = userSubs.filter( + sub => !(sub.relayId === relayId && sub.resourceId === resourceId), + ); + userWithoutDup.push(entry); + subscriptionsByUser.set(userId, userWithoutDup); + touchRoomEntry(relayId, resourceId); +} + +export function removeSubscription( + socketId: string, + userId: string | undefined, + relayId: string, + resourceId: string, +): void { + const socketSubs = subscriptionsBySocket.get(socketId); + if (socketSubs) { + const next = socketSubs.filter( + sub => !(sub.relayId === relayId && sub.resourceId === resourceId), + ); + if (next.length === 0) { + subscriptionsBySocket.delete(socketId); + } else { + subscriptionsBySocket.set(socketId, next); + } + } + if (userId && !isSubscriptionTrackedOnOtherSocket(relayId, resourceId, socketId)) { + const userSubs = subscriptionsByUser.get(userId); + if (userSubs) { + const next = userSubs.filter( + sub => !(sub.relayId === relayId && sub.resourceId === resourceId), + ); + if (next.length === 0) { + subscriptionsByUser.delete(userId); + } else { + subscriptionsByUser.set(userId, next); + } + } + pruneRoomEntryIfUnused(relayId, resourceId); + } +} + +function isSubscriptionTrackedOnOtherSocket( + relayId: string, + resourceId: string, + exceptSocketId: string, +): boolean { + for (const [socketId, subs] of subscriptionsBySocket) { + if (socketId === exceptSocketId) { + continue; + } + if (subs.some(sub => sub.relayId === relayId && sub.resourceId === resourceId)) { + return true; + } + } + return false; +} + +function isSubscriptionTrackedOnAnySocket(relayId: string, resourceId: string): boolean { + for (const subs of subscriptionsBySocket.values()) { + if (subs.some(sub => sub.relayId === relayId && sub.resourceId === resourceId)) { + return true; + } + } + return false; +} + +export function releaseSocketSubscriptions(socketId: string, userId?: string): void { + const subs = subscriptionsBySocket.get(socketId) ?? []; + subscriptionsBySocket.delete(socketId); + if (!userId) { + return; + } + for (const sub of subs) { + if (!isSubscriptionTrackedOnOtherSocket(sub.relayId, sub.resourceId, socketId)) { + const userSubs = subscriptionsByUser.get(userId); + if (!userSubs) { + continue; + } + const next = userSubs.filter( + entry => + !(entry.relayId === sub.relayId && entry.resourceId === sub.resourceId), + ); + if (next.length === 0) { + subscriptionsByUser.delete(userId); + } else { + subscriptionsByUser.set(userId, next); + } + } + pruneRoomEntryIfUnused(sub.relayId, sub.resourceId); + } +} + +export function subscriptionsForRecoveredRooms( + recoveredRooms: string[], + contextSubs: RelaySubscription[] = [], +): RelaySubscription[] { + pruneExpiredRoomEntries(); + const seen = new Set(); + const result: RelaySubscription[] = []; + const recoveredSet = new Set(recoveredRooms.filter(room => room.startsWith('er:'))); + + for (const sub of contextSubs) { + const room = eventRelayRoom(sub.relayId, sub.resourceId); + if (recoveredSet.has(room) && !seen.has(room)) { + seen.add(room); + result.push(sub); + } + } + + for (const room of recoveredSet) { + if (seen.has(room)) { + continue; + } + const entry = subscriptionsByRoom.get(room); + if (entry) { + seen.add(room); + result.push({ relayId: entry.relayId, resourceId: entry.resourceId }); + } + } + + return result; +} + +export function subscriptionsForUser(userId: string): RelaySubscription[] { + return [...(subscriptionsByUser.get(userId) ?? [])]; +} + +export function removeSubscriptionsForRelay(relayId: string): void { + for (const [socketId, subs] of subscriptionsBySocket) { + const next = subs.filter(sub => sub.relayId !== relayId); + if (next.length === 0) { + subscriptionsBySocket.delete(socketId); + } else { + subscriptionsBySocket.set(socketId, next); + } + } + for (const [userId, subs] of subscriptionsByUser) { + const next = subs.filter(sub => sub.relayId !== relayId); + if (next.length === 0) { + subscriptionsByUser.delete(userId); + } else { + subscriptionsByUser.set(userId, next); + } + } + for (const [room, entry] of subscriptionsByRoom) { + if (entry.relayId === relayId) { + subscriptionsByRoom.delete(room); + } + } +} + +export function leaveRoomsForRelay(relayId: string): string[] { + const rooms = new Set(); + for (const subs of subscriptionsByUser.values()) { + for (const sub of subs) { + if (sub.relayId === relayId) { + rooms.add(eventRelayRoom(relayId, sub.resourceId)); + } + } + } + return [...rooms]; +} + +/** @internal test helper */ +export function _clearEventRelaySubscriptionStateForTests(): void { + subscriptionsBySocket.clear(); + subscriptionsByUser.clear(); + subscriptionsByRoom.clear(); +} diff --git a/modules/router/src/index.ts b/modules/router/src/index.ts index 20a0c779e..fd1d069ed 100644 --- a/modules/router/src/index.ts +++ b/modules/router/src/index.ts @@ -1,7 +1,25 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { ConduitGrpcSdk } from '@conduitplatform/grpc-sdk'; import ConduitDefaultRouter from './Router.js'; const peerManifestRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); const router = new ConduitDefaultRouter(peerManifestRoot); + +function registerShutdownSignals(): void { + const shutdown = (signal: NodeJS.Signals) => { + void router + .shutdown() + .catch(err => { + ConduitGrpcSdk.Logger.error(err as Error); + }) + .finally(() => { + process.exit(signal === 'SIGINT' ? 130 : 0); + }); + }; + process.once('SIGTERM', () => shutdown('SIGTERM')); + process.once('SIGINT', () => shutdown('SIGINT')); +} + +registerShutdownSignals(); router.start(); diff --git a/modules/router/src/metrics/index.ts b/modules/router/src/metrics/index.ts index d3461c107..2ed45b20f 100644 --- a/modules/router/src/metrics/index.ts +++ b/modules/router/src/metrics/index.ts @@ -38,4 +38,39 @@ export default { help: 'Tracks denied or unavailable event-relay socket subscriptions', }, }, + eventRelaysActive: { + type: MetricType.Gauge, + config: { + name: 'event_relays_active_total', + help: 'Active event relays on this router replica', + }, + }, + eventRelaysSubscribedChannels: { + type: MetricType.Gauge, + config: { + name: 'event_relays_subscribed_channels_total', + help: 'Bus channels this router replica subscribes to for event relays', + }, + }, + eventRelaysEmptyRoom: { + type: MetricType.Counter, + config: { + name: 'event_relays_empty_room_total', + help: 'Skipped emits because no local sockets were in the relay room', + }, + }, + eventRelaysInboundDropped: { + type: MetricType.Counter, + config: { + name: 'event_relays_inbound_dropped_total', + help: 'Inbound bus payloads dropped for exceeding the size cap', + }, + }, + eventRelaysEmitDropped: { + type: MetricType.Counter, + config: { + name: 'event_relays_emit_dropped_total', + help: 'Socket emits dropped or disconnected due to backpressure', + }, + }, }; diff --git a/modules/router/tsconfig.test.json b/modules/router/tsconfig.test.json index 601e5ccee..229f7f155 100644 --- a/modules/router/tsconfig.test.json +++ b/modules/router/tsconfig.test.json @@ -19,6 +19,9 @@ "src/event-relays/search.ts", "src/event-relays/channels.ts", "src/event-relays/authorize.ts", + "src/event-relays/compile.ts", + "src/event-relays/rebacCache.ts", + "src/event-relays/subscriptions.ts", "src/event-relays/*.test.ts" ] } From 9e6356efccae71fd154e2ea411730a4c892bccf5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 13 Sep 2026 11:48:02 +0000 Subject: [PATCH 4/4] fix(router): address CodeFactor findings on event relay validation Split validateEventRelayInput into per-field parsers to reduce complexity. Silence unused-parameter lint in EventBus test FakeRedis stub. --- .../grpc-sdk/src/utilities/EventBus.test.ts | 25 ++-- modules/router/src/event-relays/validation.ts | 127 +++++++++++------- 2 files changed, 94 insertions(+), 58 deletions(-) diff --git a/libraries/grpc-sdk/src/utilities/EventBus.test.ts b/libraries/grpc-sdk/src/utilities/EventBus.test.ts index c1999901d..4d069c244 100644 --- a/libraries/grpc-sdk/src/utilities/EventBus.test.ts +++ b/libraries/grpc-sdk/src/utilities/EventBus.test.ts @@ -24,11 +24,15 @@ class FakeRedis { cb?.(null); } - unsubscribe(_channel: string, cb?: () => void) { + unsubscribe(channel: string, cb?: () => void) { + void channel; cb?.(); } - publish(_channel: string, _message: string) {} + publish(channel: string, message: string) { + void channel; + void message; + } quit() {} @@ -102,19 +106,18 @@ describe('EventBus', () => { const { bus, sub } = createBus(); sub.failNext.add('chan'); await assert.rejects( - () => - bus.subscribeAck( - 'chan', - () => {}, - 'relay-a', - ), + () => bus.subscribeAck('chan', () => {}, 'relay-a'), /subscribe failed/, ); sub.failNext.delete('chan'); let count = 0; - await bus.subscribeAck('chan', () => { - count += 1; - }, 'relay-a'); + await bus.subscribeAck( + 'chan', + () => { + count += 1; + }, + 'relay-a', + ); sub.emitMessage('chan', 'x'); assert.equal(count, 1); }); diff --git a/modules/router/src/event-relays/validation.ts b/modules/router/src/event-relays/validation.ts index d6c6c5eb6..c59976335 100644 --- a/modules/router/src/event-relays/validation.ts +++ b/modules/router/src/event-relays/validation.ts @@ -33,24 +33,75 @@ const PERMISSION_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,63}$/; const RESOURCE_ID_PATTERN = /^[^\s:]{1,128}$/; export function validateEventRelayInput(input: EventRelayInput): EventRelayInput { - const name = requireTrimmed(input.name, 'Name'); + const name = parseName(input.name); + const notes = parseNotes(input.notes); + const busEvent = parseBusEvent(input.busEvent); + const socketEvent = parseSocketEvent(input.socketEvent); + const resourceType = parseResourceType(input.resourceType); + const resourceIdPath = parseResourceIdPath(input.resourceIdPath); + const permission = parsePermission(input.permission); + const messageTemplate = parseMessageTemplate(input.messageTemplate); + + return { + name, + notes, + active: input.active !== false, + busEvent, + socketEvent, + resourceType, + resourceIdPath, + permission, + messageTemplate, + }; +} + +export function validateResourceId(resourceId: unknown): string { + if (typeof resourceId !== 'string' && typeof resourceId !== 'number') { + throw new EventRelayValidationError('Resource ID must be a string'); + } + const value = String(resourceId).trim(); + if ( + !value || + value.length > MAX_RESOURCE_ID_LENGTH || + !RESOURCE_ID_PATTERN.test(value) + ) { + throw new EventRelayValidationError('Resource ID is invalid'); + } + return value; +} + +function requireTrimmed(value: unknown, label: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new EventRelayValidationError(`${label} is required`); + } + return value.trim(); +} + +function parseName(raw: unknown): string { + const name = requireTrimmed(raw, 'Name'); if (name.length > MAX_NAME_LENGTH || !NAME_PATTERN.test(name)) { throw new EventRelayValidationError( 'Name must be 1-64 characters and start with a letter or number', ); } + return name; +} - const notes = - input.notes === undefined || input.notes === '' - ? undefined - : requireTrimmed(input.notes, 'Notes'); - if (notes && notes.length > MAX_DESCRIPTION_LENGTH) { +function parseNotes(raw: unknown): string | undefined { + if (raw === undefined || raw === '') { + return undefined; + } + const notes = requireTrimmed(raw, 'Notes'); + if (notes.length > MAX_DESCRIPTION_LENGTH) { throw new EventRelayValidationError( `Notes must be at most ${MAX_DESCRIPTION_LENGTH} characters`, ); } + return notes; +} - const busEvent = requireTrimmed(input.busEvent, 'Bus event'); +function parseBusEvent(raw: unknown): string { + const busEvent = requireTrimmed(raw, 'Bus event'); if ( busEvent.length > MAX_BUS_EVENT_LENGTH || busEvent.includes('*') || @@ -60,8 +111,11 @@ export function validateEventRelayInput(input: EventRelayInput): EventRelayInput 'Bus event must be an exact channel name with no wildcards', ); } + return busEvent; +} - const socketEvent = requireTrimmed(input.socketEvent, 'Socket event'); +function parseSocketEvent(raw: unknown): string { + const socketEvent = requireTrimmed(raw, 'Socket event'); if ( socketEvent.length > MAX_SOCKET_EVENT_LENGTH || !SOCKET_EVENT_PATTERN.test(socketEvent) || @@ -69,62 +123,41 @@ export function validateEventRelayInput(input: EventRelayInput): EventRelayInput ) { throw new EventRelayValidationError('Socket event must be a non-reserved event name'); } + return socketEvent; +} - const resourceType = requireTrimmed(input.resourceType, 'Resource type'); +function parseResourceType(raw: unknown): string { + const resourceType = requireTrimmed(raw, 'Resource type'); if ( resourceType.length > MAX_RESOURCE_TYPE_LENGTH || !RESOURCE_TYPE_PATTERN.test(resourceType) ) { throw new EventRelayValidationError('Resource type is invalid'); } + return resourceType; +} - const resourceIdPath = requireTrimmed(input.resourceIdPath, 'Resource ID path'); +function parseResourceIdPath(raw: unknown): string { + const resourceIdPath = requireTrimmed(raw, 'Resource ID path'); if (resourceIdPath.length > MAX_RESOURCE_ID_PATH_LENGTH) { throw new EventRelayValidationError('Resource ID path is too long'); } parseDotPath(resourceIdPath, 'Resource ID path'); + return resourceIdPath; +} - const permission = requireTrimmed(input.permission, 'Permission'); +function parsePermission(raw: unknown): string { + const permission = requireTrimmed(raw, 'Permission'); if (permission.length > MAX_PERMISSION_LENGTH || !PERMISSION_PATTERN.test(permission)) { throw new EventRelayValidationError('Permission is invalid'); } - - if (input.messageTemplate === undefined) { - throw new EventRelayValidationError('Message template is required'); - } - assertTemplateSize(input.messageTemplate); - - return { - name, - notes, - active: input.active !== false, - busEvent, - socketEvent, - resourceType, - resourceIdPath, - permission, - messageTemplate: input.messageTemplate, - }; -} - -export function validateResourceId(resourceId: unknown): string { - if (typeof resourceId !== 'string' && typeof resourceId !== 'number') { - throw new EventRelayValidationError('Resource ID must be a string'); - } - const value = String(resourceId).trim(); - if ( - !value || - value.length > MAX_RESOURCE_ID_LENGTH || - !RESOURCE_ID_PATTERN.test(value) - ) { - throw new EventRelayValidationError('Resource ID is invalid'); - } - return value; + return permission; } -function requireTrimmed(value: unknown, label: string): string { - if (typeof value !== 'string' || value.trim() === '') { - throw new EventRelayValidationError(`${label} is required`); +function parseMessageTemplate(raw: unknown): unknown { + if (raw === undefined) { + throw new EventRelayValidationError('Message template is required'); } - return value.trim(); + assertTemplateSize(raw); + return raw; }