@jskit-ai/realtime 0.1.175 → 0.1.177

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/realtime",
3
- "version": "0.1.175",
3
+ "version": "0.1.177",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -20,8 +20,8 @@
20
20
  "socket.io-client": "^4.8.3"
21
21
  },
22
22
  "peerDependencies": {
23
- "@jskit-ai/kernel": "0.1.178",
24
- "@jskit-ai/shell-web": "0.1.182",
23
+ "@jskit-ai/kernel": "0.1.180",
24
+ "@jskit-ai/shell-web": "0.1.184",
25
25
  "vue": "^3.5.13"
26
26
  },
27
27
  "description": "Thin, generic realtime runtime wrappers for socket.io server and client.",
@@ -53,9 +53,26 @@ Omit the placement when the product does not need a visible connection state.
53
53
  Change event listeners, Redis provisioning, and client invalidation behavior to
54
54
  match the product. Keep transport retry policy in the realtime runtime.
55
55
 
56
+ When every realtime event belongs behind the application's authentication
57
+ boundary, let the selected `auth.service` opt in with
58
+ `realtime.requireAuthentication: true`. Socket.IO then rejects an
59
+ unauthenticated handshake before the client can join any broadcast room. A
60
+ browser whose login identity changes must disconnect and reconnect its socket
61
+ so the next handshake resolves the new actor.
62
+
63
+ Use `auth.service.realtime.authorizeEvent({ actor, event: { name, payload } })`
64
+ for resource-specific read authorization. Return `true` only when the current
65
+ actor can read that event's resource. Authentication is revalidated before
66
+ delivery and every 30 seconds while idle. Redis peers perform the same checks
67
+ locally. Realtime failures are logged without failing an already-successful
68
+ mutation; one mutation owner publishes each completion, while progress events
69
+ describe distinct lifecycle states.
70
+
56
71
  ## Verification
57
72
 
58
73
  - Test in-process delivery without Redis.
74
+ - When authenticated-client mode is selected, reject an anonymous handshake
75
+ and reconnect after login, logout, and identity changes.
59
76
  - When Redis is selected, test delivery across two server processes.
60
77
  - Disconnect and reconnect a browser and verify recovery behavior.
61
78
  - Confirm the status contribution renders in compact and expanded shells.
@@ -2,7 +2,7 @@
2
2
  "private": true,
3
3
  "type": "module",
4
4
  "dependencies": {
5
- "@jskit-ai/realtime": "0.1.175",
6
- "@jskit-ai/shell-web": "0.1.182"
5
+ "@jskit-ai/realtime": "0.1.177",
6
+ "@jskit-ai/shell-web": "0.1.184"
7
7
  }
8
8
  }
@@ -2,7 +2,10 @@ import { defineProvider } from "@jskit-ai/kernel/shared/capabilities";
2
2
  import { createProviderLogger } from "@jskit-ai/kernel/shared/support/providerLogger";
3
3
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
4
4
  import { createRealtimeDelivery } from "./realtimeDelivery.js";
5
- import { registerSocketAudienceBootstrap } from "./realtimeAudience.js";
5
+ import {
6
+ realtimeAuthenticationRequired,
7
+ registerSocketAudienceBootstrap
8
+ } from "./realtimeAudience.js";
6
9
  import {
7
10
  closeSocketIoRedisConnections,
8
11
  closeSocketIoServer,
@@ -32,6 +35,7 @@ function createRealtimeCapability({ io }) {
32
35
  diagnostics() {
33
36
  const state = stateByCapability.get(capability);
34
37
  return Object.freeze({
38
+ authenticationRequired: state?.authenticationRequired === true,
35
39
  connectedClients: Number.isInteger(Number(io?.engine?.clientsCount))
36
40
  ? Number(io.engine.clientsCount)
37
41
  : null,
@@ -59,12 +63,18 @@ const RealtimeProvider = defineProvider({
59
63
  provides: {
60
64
  realtime: "runtime.realtime"
61
65
  },
62
- setup({ config, database, env, events, fastify, logger }) {
66
+ setup({ authService, config, database, env, events, fastify, logger, workspaces }) {
63
67
  const io = createSocketIoServer({ fastify });
64
68
  const providerLogger = createProviderLogger(logger, { debugEnabled: debugEnabled(config, env) });
65
- const delivery = createRealtimeDelivery({ io, database, logger: providerLogger });
69
+ const delivery = createRealtimeDelivery({ io, database, logger: providerLogger, authService, workspaces });
66
70
  const realtime = createRealtimeCapability({ io });
67
- stateByCapability.set(realtime, { io, providerLogger, redisConnection: null });
71
+ stateByCapability.set(realtime, {
72
+ authenticationRequired: false,
73
+ delivery,
74
+ io,
75
+ providerLogger,
76
+ redisConnection: null
77
+ });
68
78
  events.register({
69
79
  id: "runtime.realtime.delivery",
70
80
  matches: (event) => Boolean(normalizeText(event?.realtime?.event)),
@@ -75,6 +85,7 @@ const RealtimeProvider = defineProvider({
75
85
  async boot({ authService, env, workspaces }, { outputs }) {
76
86
  const state = stateByCapability.get(outputs.realtime);
77
87
  if (!state) throw new Error("Realtime runtime state is unavailable.");
88
+ state.authenticationRequired = realtimeAuthenticationRequired(authService);
78
89
  registerSocketAudienceBootstrap({
79
90
  io: state.io,
80
91
  logger: state.providerLogger,
@@ -82,13 +93,16 @@ const RealtimeProvider = defineProvider({
82
93
  workspaces
83
94
  });
84
95
  state.redisConnection = await configureSocketIoRedisAdapter(state.io, {
96
+ logger: state.providerLogger,
85
97
  redisUrl: resolveRealtimeRedisUrl(env),
86
98
  redisNamespace: resolveRealtimeRedisNamespace(env)
87
99
  });
100
+ state.delivery.start({ redisConfigured: state.redisConnection.enabled });
88
101
  },
89
102
  async shutdown(_dependencies, { outputs }) {
90
103
  const state = stateByCapability.get(outputs.realtime);
91
104
  if (!state) return;
105
+ state.delivery.stop();
92
106
  await closeSocketIoServer(state.io);
93
107
  await closeSocketIoRedisConnections(state.redisConnection || {});
94
108
  stateByCapability.delete(outputs.realtime);
@@ -171,7 +171,7 @@ function parseCookieHeader(value = "") {
171
171
  return cookies;
172
172
  }
173
173
 
174
- async function resolveSocketActorId(authService, socket) {
174
+ async function resolveSocketActor(authService, socket) {
175
175
  if (typeof authService?.authenticateRequest !== "function") return null;
176
176
  const handshakeHeaders = socket?.handshake?.headers || {};
177
177
  const requestHeaders = socket?.request?.headers || {};
@@ -185,20 +185,57 @@ async function resolveSocketActorId(authService, socket) {
185
185
  if (host) request.headers = { host };
186
186
  if (remoteAddress) request.socket = { remoteAddress };
187
187
  const result = await authService.authenticateRequest(request);
188
- return result?.authenticated === true
189
- ? normalizeRecordId(result?.actor?.id, { fallback: null })
190
- : null;
188
+ const id = result?.authenticated === true
189
+ ? normalizeRecordId(result?.actor?.id, { fallback: null }) : null;
190
+ return id ? { ...result.actor, id } : null;
191
+ }
192
+
193
+ function realtimeAuthenticationRequired(authService = null) {
194
+ return authService?.realtime?.requireAuthentication === true;
195
+ }
196
+
197
+ function authenticationRequiredError() {
198
+ const error = new Error("Authentication required.");
199
+ error.data = Object.freeze({ code: "AUTHENTICATION_REQUIRED" });
200
+ return error;
201
+ }
202
+
203
+ function rememberSocketActorId(socket, actorId) {
204
+ socket.data = socket.data && typeof socket.data === "object" ? socket.data : {};
205
+ socket.data.actorId = actorId;
206
+ }
207
+
208
+ function registerRequiredSocketAuthentication({ io, logger, authService }) {
209
+ if (!realtimeAuthenticationRequired(authService)) return;
210
+ if (typeof io?.use !== "function") {
211
+ throw new TypeError("Realtime authenticated-client mode requires Socket.IO middleware support.");
212
+ }
213
+ io.use(async (socket, next) => {
214
+ try {
215
+ const actorId = (await resolveSocketActor(authService, socket))?.id;
216
+ if (!actorId) {
217
+ next(authenticationRequiredError());
218
+ return;
219
+ }
220
+ rememberSocketActorId(socket, actorId);
221
+ next();
222
+ } catch (error) {
223
+ logger.warn({ error: String(error?.message || error) }, "Realtime socket authentication failed.");
224
+ next(authenticationRequiredError());
225
+ }
226
+ });
191
227
  }
192
228
 
193
229
  function registerSocketAudienceBootstrap({ io, logger, authService = null, workspaces = null }) {
194
230
  if (typeof io?.on !== "function") return;
231
+ registerRequiredSocketAuthentication({ io, logger, authService });
195
232
  io.on("connection", async (socket) => {
196
233
  try {
197
234
  socket.join(ALL_CLIENTS_ROOM);
198
- const actorId = await resolveSocketActorId(authService, socket);
235
+ const actorId = normalizeRecordId(socket?.data?.actorId, { fallback: null })
236
+ || (await resolveSocketActor(authService, socket))?.id;
199
237
  if (!actorId) return;
200
- socket.data = socket.data && typeof socket.data === "object" ? socket.data : {};
201
- socket.data.actorId = actorId;
238
+ rememberSocketActorId(socket, actorId);
202
239
  socket.join(ALL_USERS_ROOM);
203
240
  socket.join(roomForUser(actorId));
204
241
  const repository = workspaces?.repositories?.workspaceMemberships;
@@ -217,4 +254,35 @@ function registerSocketAudienceBootstrap({ io, logger, authService = null, works
217
254
  });
218
255
  }
219
256
 
220
- export { registerSocketAudienceBootstrap, resolveAudienceTargets };
257
+ async function revalidateSocket({ socket, authService, workspaces = null }) {
258
+ const actor = await resolveSocketActor(authService, socket);
259
+ const previousActorId = socket.data?.actorId;
260
+ if ((!actor && realtimeAuthenticationRequired(authService)) ||
261
+ (previousActorId && previousActorId !== actor?.id)) {
262
+ socket.disconnect(true);
263
+ return null;
264
+ }
265
+ if (actor) {
266
+ rememberSocketActorId(socket, actor.id);
267
+ const repository = workspaces?.repositories?.workspaceMemberships;
268
+ if (typeof repository?.listActiveWorkspaceIdsByUserId === "function") {
269
+ const workspaceIds = await repository.listActiveWorkspaceIdsByUserId(actor.id);
270
+ const rooms = new Set(normalizeArray(workspaceIds).flatMap((id) => {
271
+ const workspaceId = normalizeRecordId(id, { fallback: null });
272
+ return workspaceId ? [roomForWorkspace(workspaceId), roomForWorkspaceUser(workspaceId, actor.id)] : [];
273
+ }));
274
+ for (const room of socket.rooms) {
275
+ if (room.startsWith("workspace:") && !rooms.has(room)) await socket.leave(room);
276
+ }
277
+ for (const room of rooms) await socket.join(room);
278
+ }
279
+ }
280
+ return actor;
281
+ }
282
+
283
+ export {
284
+ realtimeAuthenticationRequired,
285
+ revalidateSocket,
286
+ registerSocketAudienceBootstrap,
287
+ resolveAudienceTargets
288
+ };
@@ -1,8 +1,14 @@
1
1
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
- import { resolveAudienceTargets } from "./realtimeAudience.js";
2
+ import { resolveAudienceTargets, revalidateSocket } from "./realtimeAudience.js";
3
+
4
+ const DELIVERY_EVENT = "jskit:realtime:delivery";
3
5
 
4
6
  function publicRealtimePayload(event) {
5
- const { realtime, ...canonical } = event && typeof event === "object" ? event : {};
7
+ const { realtime } = event && typeof event === "object" ? event : {};
8
+ const canonical = {};
9
+ for (const key of ["type", "source", "entity", "operation", "entityId", "scope", "actorId", "commandId", "sourceClientId", "occurredAt"]) {
10
+ if (Object.hasOwn(event, key)) canonical[key] = event[key];
11
+ }
6
12
  const payload = realtime?.payload;
7
13
  if (payload != null && (!payload || typeof payload !== "object" || Array.isArray(payload))) {
8
14
  throw new TypeError("Realtime event payload must be an object when provided.");
@@ -10,7 +16,7 @@ function publicRealtimePayload(event) {
10
16
  return Object.freeze({ ...(payload || {}), ...canonical });
11
17
  }
12
18
 
13
- function createRealtimeDelivery({ io, database = null, logger }) {
19
+ function createRealtimeDelivery({ io, database = null, logger, authService = null, workspaces = null }) {
14
20
  if (!io || typeof io.emit !== "function" || typeof io.to !== "function") {
15
21
  throw new TypeError("Realtime delivery requires a Socket.IO server.");
16
22
  }
@@ -18,22 +24,104 @@ function createRealtimeDelivery({ io, database = null, logger }) {
18
24
  throw new TypeError("Realtime delivery requires a logger.");
19
25
  }
20
26
 
27
+ const guarded = typeof authService?.authenticateRequest === "function";
28
+ if (authService?.realtime?.authorizeEvent != null && typeof authService.realtime.authorizeEvent !== "function") {
29
+ throw new TypeError("Realtime authorizeEvent must be a function.");
30
+ }
31
+ if ((authService?.realtime?.requireAuthentication === true || authService?.realtime?.authorizeEvent) && !guarded) {
32
+ throw new TypeError("Realtime authorization requires auth.service.authenticateRequest().");
33
+ }
34
+ let redisEnabled = false;
35
+ let revalidationTimer = null;
36
+ let stopped = false;
37
+
38
+ async function authenticate(socket) {
39
+ try {
40
+ return await revalidateSocket({ socket, authService, workspaces });
41
+ } catch (error) {
42
+ socket.disconnect(true);
43
+ logger.warn({ error: String(error?.message || error) }, "Realtime socket revalidation failed.");
44
+ return null;
45
+ }
46
+ }
47
+
48
+ async function deliverLocal({ eventName, payload, targets }) {
49
+ await Promise.all([...io.sockets.sockets.values()].map(async (socket) => {
50
+ try {
51
+ const actor = await authenticate(socket);
52
+ if (!socket.connected) return;
53
+ if (!targets.broadcastAllClients && !targets.rooms.some((room) => socket.rooms.has(room))) return;
54
+ if (typeof authService.realtime?.authorizeEvent === "function" &&
55
+ await authService.realtime.authorizeEvent({ actor, event: { name: eventName, payload } }) !== true) return;
56
+ if (socket.connected) socket.emit(eventName, payload);
57
+ } catch (error) {
58
+ logger.warn({ socketEvent: eventName, error: String(error?.message || error) }, "Realtime delivery denied after authorization failure.");
59
+ }
60
+ }));
61
+ }
62
+
63
+ async function receive(envelope) {
64
+ try {
65
+ await deliverLocal(envelope);
66
+ } catch (error) {
67
+ logger.warn({ error: String(error?.message || error) }, "Realtime peer delivery failed.");
68
+ }
69
+ }
70
+
71
+ function scheduleRevalidation() {
72
+ if (stopped) return;
73
+ revalidationTimer = setTimeout(async () => {
74
+ await Promise.all([...io.sockets.sockets.values()].map(authenticate));
75
+ scheduleRevalidation();
76
+ }, 30_000);
77
+ revalidationTimer.unref();
78
+ }
79
+
21
80
  return Object.freeze({
81
+ start({ redisConfigured = false } = {}) {
82
+ redisEnabled = redisConfigured;
83
+ if (guarded) {
84
+ io.on(DELIVERY_EVENT, receive);
85
+ scheduleRevalidation();
86
+ }
87
+ },
88
+ stop() {
89
+ stopped = true;
90
+ clearTimeout(revalidationTimer);
91
+ if (guarded) io.off(DELIVERY_EVENT, receive);
92
+ },
22
93
  async handle(event = {}) {
23
- const eventName = normalizeText(event?.realtime?.event);
24
- if (!eventName) return;
25
- const targets = await resolveAudienceTargets(event.realtime.audience, event, { database, logger });
26
- const payload = publicRealtimePayload(event);
27
- if (targets.broadcastAllClients) io.emit(eventName, payload);
28
- for (const room of targets.rooms) io.to(room).emit(eventName, payload);
29
- logger.debug({
30
- socketEvent: eventName,
31
- rooms: targets.rooms,
32
- broadcastAllClients: targets.broadcastAllClients,
33
- connectedClients: Number.isInteger(Number(io?.engine?.clientsCount))
34
- ? Number(io.engine.clientsCount)
35
- : null
36
- }, "Realtime delivered an action event.");
94
+ try {
95
+ const eventName = normalizeText(event?.realtime?.event);
96
+ if (!eventName) return;
97
+ const targets = await resolveAudienceTargets(event.realtime.audience, event, { database, logger });
98
+ const payload = publicRealtimePayload(event);
99
+ if (guarded) {
100
+ const envelope = { eventName, payload, targets };
101
+ if (redisEnabled) {
102
+ try {
103
+ io.serverSideEmit(DELIVERY_EVENT, envelope);
104
+ } catch (error) {
105
+ logger.warn({ socketEvent: eventName, error: String(error?.message || error) }, "Realtime peer publication failed.");
106
+ }
107
+ }
108
+ await deliverLocal(envelope);
109
+ } else if (targets.broadcastAllClients) {
110
+ io.emit(eventName, payload);
111
+ } else if (targets.rooms.length) {
112
+ io.to(targets.rooms).emit(eventName, payload);
113
+ }
114
+ logger.debug({
115
+ socketEvent: eventName,
116
+ rooms: targets.rooms,
117
+ broadcastAllClients: targets.broadcastAllClients,
118
+ connectedClients: Number.isInteger(Number(io?.engine?.clientsCount))
119
+ ? Number(io.engine.clientsCount)
120
+ : null
121
+ }, "Realtime delivered an action event.");
122
+ } catch (error) {
123
+ logger.warn({ socketEvent: event?.realtime?.event, error: String(error?.message || error) }, "Realtime delivery failed; the domain mutation remains successful.");
124
+ }
37
125
  }
38
126
  });
39
127
  }
@@ -81,6 +81,7 @@ async function configureSocketIoRedisAdapter(
81
81
  {
82
82
  redisUrl = "",
83
83
  redisNamespace = "",
84
+ logger = console,
84
85
  createRedisAdapter = createSocketIoRedisAdapter,
85
86
  createRedisConnection = createRedisClient
86
87
  } = {}
@@ -105,6 +106,19 @@ async function configureSocketIoRedisAdapter(
105
106
  url: normalizedRedisUrl
106
107
  });
107
108
  const subClient = pubClient.duplicate();
109
+ const reportError = (error) => logger.warn({ error: String(error?.message || error) }, "Realtime Redis connection failed.");
110
+ pubClient.on("error", reportError);
111
+ subClient.on("error", reportError);
112
+ const publish = pubClient.publish.bind(pubClient);
113
+ // The Socket.IO adapter does not await Redis publish promises.
114
+ pubClient.publish = async (...args) => {
115
+ try {
116
+ return await publish(...args);
117
+ } catch (error) {
118
+ reportError(error);
119
+ return 0;
120
+ }
121
+ };
108
122
 
109
123
  try {
110
124
  await pubClient.connect();
@@ -0,0 +1,144 @@
1
+ import assert from "node:assert/strict";
2
+ import { createServer } from "node:http";
3
+ import { once } from "node:events";
4
+ import test from "node:test";
5
+ import { io as connect } from "socket.io-client";
6
+ import { createSocketIoServer, closeSocketIoServer } from "../src/server/runtime.js";
7
+ import { createRealtimeDelivery } from "../src/server/realtimeDelivery.js";
8
+ import { registerSocketAudienceBootstrap } from "../src/server/realtimeAudience.js";
9
+
10
+ const logger = { debug() {}, warn() {} };
11
+ const event = {
12
+ type: "entity.changed", entity: "session", entityId: "s1",
13
+ meta: { secret: "server-only" },
14
+ realtime: {
15
+ audience: ["all_clients", { userId: 1 }], event: "conversation.changed",
16
+ payload: { projectSlug: "private", conversationLogPatch: { text: "private answer" } }
17
+ }
18
+ };
19
+
20
+ test("invalid authorization configuration fails closed at startup", () => {
21
+ const io = { emit() {}, to() {} };
22
+ assert.throws(() => createRealtimeDelivery({ io, logger, authService: {
23
+ authenticateRequest() {}, realtime: { authorizeEvent: true }
24
+ } }), /authorizeEvent must be a function/u);
25
+ assert.throws(() => createRealtimeDelivery({ io, logger, authService: {
26
+ realtime: { requireAuthentication: true }
27
+ } }), /authenticateRequest/u);
28
+ });
29
+
30
+ test("real sockets authorize each conversation delivery and disconnect a revoked session", async (t) => {
31
+ const httpServer = createServer();
32
+ const io = createSocketIoServer({ httpServer });
33
+ const users = new Map([["allowed", { id: "1" }], ["denied", { id: "2" }]]);
34
+ let permitted = "1";
35
+ const authService = {
36
+ realtime: {
37
+ requireAuthentication: true,
38
+ authorizeEvent: ({ actor }) => actor.id === permitted
39
+ },
40
+ async authenticateRequest(request) {
41
+ const actor = users.get(request.cookies.session);
42
+ return { authenticated: Boolean(actor), actor };
43
+ }
44
+ };
45
+ registerSocketAudienceBootstrap({ io, authService, logger });
46
+ const delivery = createRealtimeDelivery({ io, authService, logger });
47
+ delivery.start();
48
+ t.after(async () => { delivery.stop(); await closeSocketIoServer(io); });
49
+ httpServer.listen(0, "127.0.0.1");
50
+ await once(httpServer, "listening");
51
+ const url = `http://127.0.0.1:${httpServer.address().port}`;
52
+ const received = { allowed: [], denied: [] };
53
+ const clients = {};
54
+ for (const session of ["allowed", "denied"]) {
55
+ const socket = connect(url, { transports: ["websocket"], extraHeaders: { Cookie: `session=${session}` }, reconnection: false });
56
+ clients[session] = socket;
57
+ t.after(() => socket.disconnect());
58
+ socket.on("conversation.changed", (payload) => received[session].push(payload));
59
+ await once(socket, "connect");
60
+ }
61
+ const answer = once(clients.allowed, "conversation.changed");
62
+ await delivery.handle(event);
63
+ await answer;
64
+ assert.equal(received.allowed.length, 1);
65
+ assert.equal(received.allowed[0].conversationLogPatch.text, "private answer");
66
+ assert.equal(Object.hasOwn(received.allowed[0], "meta"), false);
67
+ assert.equal(received.denied.length, 0);
68
+ permitted = "2";
69
+ const nextAnswer = once(clients.denied, "conversation.changed");
70
+ await delivery.handle(event);
71
+ await nextAnswer;
72
+ assert.equal(received.allowed.length, 1);
73
+ users.delete("denied");
74
+ const disconnected = once(clients.denied, "disconnect");
75
+ await delivery.handle(event);
76
+ await disconnected;
77
+ assert.equal(received.denied.length, 1);
78
+ });
79
+
80
+ test("idle socket sessions are revalidated without an application event", async (t) => {
81
+ t.mock.timers.enable({ apis: ["setTimeout"] });
82
+ const socket = { data: { actorId: "1" }, handshake: {}, disconnect() { this.connected = false; }, connected: true };
83
+ const io = { emit() {}, to() {}, on() {}, off() {}, sockets: { sockets: new Map([["s1", socket]]) } };
84
+ const delivery = createRealtimeDelivery({ io, logger, authService: {
85
+ realtime: { requireAuthentication: true },
86
+ async authenticateRequest() { return { authenticated: false }; }
87
+ } });
88
+ t.after(() => delivery.stop());
89
+ delivery.start();
90
+ t.mock.timers.tick(30_000);
91
+ await new Promise((resolve) => setImmediate(resolve));
92
+ assert.equal(socket.connected, false);
93
+ });
94
+
95
+ test("delivery failures never reject a committed mutation and room unions emit once", async () => {
96
+ const warnings = [];
97
+ const io = { emit() { throw new Error("transport down"); }, to() { throw new Error("transport down"); } };
98
+ const delivery = createRealtimeDelivery({ io, logger: { debug() {}, warn(value) { warnings.push(value); } } });
99
+ await assert.doesNotReject(delivery.handle(event));
100
+ await assert.doesNotReject(delivery.handle({ ...event, realtime: { ...event.realtime, payload: "invalid" } }));
101
+ assert.equal(warnings.length, 2);
102
+ });
103
+
104
+ test("Redis peer deliveries authorize the receiving server's sockets without rebroadcasting", async () => {
105
+ const delivered = [];
106
+ const peerHandlers = new Map();
107
+ const socket = {
108
+ connected: true, data: { actorId: "2" }, handshake: {}, rooms: new Set(["users", "user:2"]),
109
+ disconnect() { this.connected = false; },
110
+ emit(name, payload) { delivered.push({ name, payload }); }
111
+ };
112
+ let allowed = false;
113
+ const authService = {
114
+ realtime: { requireAuthentication: true, authorizeEvent: () => allowed },
115
+ async authenticateRequest() { return { authenticated: true, actor: { id: "2" } }; }
116
+ };
117
+ const peer = createRealtimeDelivery({ logger, authService, io: {
118
+ emit() { assert.fail("must authorize individual sockets"); }, to() {},
119
+ sockets: { sockets: new Map([["peer-socket", socket]]) },
120
+ on(name, handler) { peerHandlers.set(name, handler); },
121
+ off(name) { peerHandlers.delete(name); },
122
+ serverSideEmit() { assert.fail("must not rebroadcast a received event"); }
123
+ } });
124
+ const pending = [];
125
+ const origin = createRealtimeDelivery({ logger, authService, io: {
126
+ emit() {}, to() {}, sockets: { sockets: new Map() }, on() {}, off() {},
127
+ serverSideEmit(name, envelope) { pending.push(peerHandlers.get(name)(structuredClone(envelope))); }
128
+ } });
129
+ peer.start({ redisConfigured: true });
130
+ origin.start({ redisConfigured: true });
131
+ try {
132
+ await origin.handle(event);
133
+ await Promise.all(pending);
134
+ assert.equal(delivered.length, 0);
135
+ allowed = true;
136
+ await origin.handle(event);
137
+ await Promise.all(pending);
138
+ assert.equal(delivered.length, 1);
139
+ assert.equal(delivered[0].payload.conversationLogPatch.text, "private answer");
140
+ } finally {
141
+ origin.stop();
142
+ peer.stop();
143
+ }
144
+ });
@@ -72,7 +72,7 @@ test("realtime delivery sends explicit action events to their selected rooms", a
72
72
  });
73
73
 
74
74
  assert.deepEqual(io.emitted, [{
75
- room: "workspace:11",
75
+ room: ["workspace:11"],
76
76
  eventName: "workspace.settings.changed",
77
77
  payload: {
78
78
  workspaceSlug: "acme",
@@ -115,7 +115,7 @@ test("realtime delivery resolves an explicit database-backed audience without ex
115
115
  }
116
116
  });
117
117
  assert.equal(io.emitted.length, 1);
118
- assert.equal(io.emitted[0].room, "user:55");
118
+ assert.deepEqual(io.emitted[0].room, ["user:55"]);
119
119
  assert.equal(Object.hasOwn(io.emitted[0].payload, "realtime"), false);
120
120
  });
121
121
 
@@ -168,6 +168,58 @@ test("socket audience bootstrap authenticates explicitly and joins actor workspa
168
168
  ]);
169
169
  });
170
170
 
171
+ test("socket audience bootstrap rejects unauthenticated handshakes when the auth service requires them", async () => {
172
+ let connectionHandler = null;
173
+ let authenticationMiddleware = null;
174
+ const io = {
175
+ on(eventName, handler) {
176
+ if (eventName === "connection") connectionHandler = handler;
177
+ },
178
+ use(handler) {
179
+ authenticationMiddleware = handler;
180
+ }
181
+ };
182
+ registerSocketAudienceBootstrap({
183
+ io,
184
+ logger,
185
+ authService: {
186
+ realtime: { requireAuthentication: true },
187
+ async authenticateRequest(request) {
188
+ return request.cookies.session === "valid"
189
+ ? { authenticated: true, actor: { id: 17 } }
190
+ : { authenticated: false, actor: null };
191
+ }
192
+ }
193
+ });
194
+
195
+ const rejected = [];
196
+ await authenticationMiddleware({
197
+ data: {},
198
+ handshake: { headers: { cookie: "session=invalid" } },
199
+ request: { headers: {} }
200
+ }, (error) => rejected.push(error));
201
+ assert.equal(rejected.length, 1);
202
+ assert.equal(rejected[0].data.code, "AUTHENTICATION_REQUIRED");
203
+
204
+ const socket = {
205
+ data: {},
206
+ handshake: { headers: { cookie: "session=valid" } },
207
+ request: { headers: {} },
208
+ join(room) {
209
+ this.joinedRooms ||= [];
210
+ this.joinedRooms.push(room);
211
+ }
212
+ };
213
+ let acceptedError = "not-called";
214
+ await authenticationMiddleware(socket, (error) => {
215
+ acceptedError = error;
216
+ });
217
+ assert.equal(acceptedError, undefined);
218
+ await connectionHandler(socket);
219
+ assert.equal(socket.data.actorId, "17");
220
+ assert.deepEqual(socket.joinedRooms, ["clients", "users", "user:17"]);
221
+ });
222
+
171
223
  async function startRealtimeClient({ mobile = null } = {}) {
172
224
  const registrations = new Map();
173
225
  const provided = new Map();
@@ -169,12 +169,15 @@ test("configureSocketIoRedisAdapter applies namespaced adapter key when redis na
169
169
  const createRedisConnection = ({ url }) => {
170
170
  const client = {
171
171
  url,
172
+ on() {},
173
+ async publish() { throw new Error("Redis publication unavailable"); },
172
174
  async connect() {
173
175
  connectionCalls.push(`${url}:connect`);
174
176
  },
175
177
  async quit() {},
176
178
  duplicate() {
177
179
  return {
180
+ on() {},
178
181
  async connect() {
179
182
  connectionCalls.push(`${url}:duplicate.connect`);
180
183
  },
@@ -191,6 +194,7 @@ test("configureSocketIoRedisAdapter applies namespaced adapter key when redis na
191
194
  });
192
195
 
193
196
  const result = await configureSocketIoRedisAdapter(io, {
197
+ logger: { warn() {} },
194
198
  redisUrl: "redis://localhost:6379",
195
199
  redisNamespace: "my-app:production",
196
200
  createRedisConnection,
@@ -202,4 +206,5 @@ test("configureSocketIoRedisAdapter applies namespaced adapter key when redis na
202
206
  assert.equal(adapterCalls[0].options?.key, "my-app:production:socket.io");
203
207
  assert.equal(result.adapterKey, "my-app:production:socket.io");
204
208
  assert.equal(result.redisNamespace, "my-app:production");
209
+ assert.equal(await result.pubClient.publish("channel", "payload"), 0);
205
210
  });